Merge branch 'stable-3.3'

* stable-3.3:
  Bazel: Fix source jar fetching with provided classifier
  Don't mention MacOS specific Java installations
  Remove documentation for unlimited strength JCE policy files
  Update git submodules
  gr-search-bar: Autosuggest before/after
  Fix long range comment selection on Firefox
  Add is:merge operator to search bar autocompletion
  Decorate the number line element of a blank side
  Update git submodules
  Change --gr-formatted-text-prose-max-width from 80ch to 120ch

Change-Id: I2476b96008151e6fd539178d978d8200e39e5e52
diff --git a/BUILD b/BUILD
index c48b3b9..084d383 100644
--- a/BUILD
+++ b/BUILD
@@ -56,19 +56,22 @@
 API_DEPS = [
     "//java/com/google/gerrit/acceptance:framework_deploy.jar",
     "//java/com/google/gerrit/acceptance:libframework-lib-src.jar",
-    "//java/com/google/gerrit/acceptance:framework-javadoc",
     "//java/com/google/gerrit/extensions:extension-api_deploy.jar",
     "//java/com/google/gerrit/extensions:libapi-src.jar",
-    "//java/com/google/gerrit/extensions:extension-api-javadoc",
     "//plugins:plugin-api_deploy.jar",
     "//plugins:plugin-api-sources_deploy.jar",
+]
+
+API_JAVADOC_DEPS = [
+    "//java/com/google/gerrit/acceptance:framework-javadoc",
+    "//java/com/google/gerrit/extensions:extension-api-javadoc",
     "//plugins:plugin-api-javadoc",
 ]
 
 genrule2(
     name = "api",
     testonly = True,
-    srcs = API_DEPS,
+    srcs = API_DEPS + API_JAVADOC_DEPS,
     outs = ["api.zip"],
     cmd = " && ".join([
         "cp $(SRCS) $$TMP",
@@ -76,3 +79,15 @@
         "zip -qr $$ROOT/$@ .",
     ]),
 )
+
+genrule2(
+    name = "api-skip-javadoc",
+    testonly = True,
+    srcs = API_DEPS,
+    outs = ["api-skip-javadoc.zip"],
+    cmd = " && ".join([
+        "cp $(SRCS) $$TMP",
+        "cd $$TMP",
+        "zip -qr $$ROOT/$@ .",
+    ]),
+)
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index e2d3c6a..2a019ca 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-accounts.txt b/Documentation/config-accounts.txt
index b4a5cef..c6d9fb4 100644
--- a/Documentation/config-accounts.txt
+++ b/Documentation/config-accounts.txt
@@ -343,6 +343,12 @@
 The `accountId` field is mandatory. The `email` and `password` fields
 are optional.
 
+Note that git will automatically nest these notes at varying levels. If
+refs/meta/external-ids:7c/2a55657d911109dbc930836e7a770fb946e8ef is not
+found then check
+refs/meta/external-ids:7c/2a/55657d911109dbc930836e7a770fb946e8ef and
+so on.
+
 The external IDs are maintained by Gerrit. This means users are not
 allowed to manually edit their external IDs. Only users with the
 link:access-control.html#capability_accessDatabase[Access Database]
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 9d7e93b..23720460 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -994,6 +994,11 @@
 be expensive to compute (60 or more seconds for a large history
 like the Linux kernel repository).
 
+cache `"comment_context"`::
++
+Caches the context lines of comments, which are the lines of the source file
+highlighted by the user when the comment was written.
+
 cache `"groups"`::
 +
 Caches the basic group information of internal groups by group ID,
@@ -1314,17 +1319,6 @@
 +
 The default is false.
 
-[[change.largeChange]]change.largeChange::
-+
-Number of changed lines from which on a change is considered as a large
-change. The number of changed lines of a change is the sum of the lines
-that were inserted and deleted in the change.
-+
-The specified value is used to visualize the change sizes in the Web UI
-in change tables and user dashboards.
-+
-By default 500.
-
 [[change.maxComments]]change.maxComments::
 +
 Maximum number of comments (regular plus robot) allowed per change. Additional
@@ -1332,6 +1326,20 @@
 +
 By default 5,000.
 
+[[change.maxFiles]]change.maxFiles::
++
+Maximum number of files allowed per change. Larger changes are rejected and must
+be split up.
++
+By default 100,000.
+
+[[change.maxPatchSets]]change.maxPatchSets::
++
+Maximum number of patch sets allowed per change. If this is insufficient,
+recreate the change with a new Change-Id, then abandon the old change.
++
+By default 1,500.
+
 [[change.maxUpdates]]change.maxUpdates::
 +
 Maximum number of updates to a change. Counts only updates to the main NoteDb
@@ -1721,6 +1729,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::
 +
@@ -2501,6 +2515,18 @@
 [[groups]]
 === Section groups
 
+[[groups.includeExternalUsersInRegisteredUsersGroup]]groups.includeExternalUsersInRegisteredUsersGroup::
++
+Controls whether external users (these are users we have sufficient
+knowledge about but who don't yet have a Gerrit account) are considered
+to be members of the `REGISTERED_USERS` group.
++
+This setting only makes sense if you run custom code (e.g. from a plugin
+or a custom authentication backend). By default, Gerrit core always requires
+users to register and doesn't use external users.
++
+By default, true.
+
 [[groups.newGroupsVisibleToAll]]groups.newGroupsVisibleToAll::
 +
 Controls whether newly created groups should be by default visible to
@@ -3312,6 +3338,19 @@
 +
 Defaults to 10000.
 
+[[elasticsearch.connectTimeout]]elasticsearch.connectTimeout::
++
+Sets the timeout for connecting to elasticsearch.
++
+Defaults to `1 second`.
+
+[[elasticsearch.socketTimeout]]elasticsearch.socketTimeout::
++
+Sets the timeout for the underlying connection. For more information, refer to
+link:#httpd.idleTimeout[`httpd.idleTimeout`].
++
+Defaults to `30 seconds`.
+
 ==== Elasticsearch Security
 
 When security is enabled in Elasticsearch, the username and password must be provided.
@@ -3373,9 +3412,9 @@
 [[experiments]]
 === Section experiments
 
-This section covers experimental new features. Gerrit's frontend uses experiments
-to research new behavior. Once the research is done, the experimental feature
-either stays and the experimentation flag gets removed, or the feature as a whole
+This section covers experimental new features. Gerrit uses experiments
+to research new behavior in frontend and core backend. Once the research is done, the experimental
+feature either stays and the experimentation flag gets removed, or the feature as a whole
 gets removed
 
 [[experiments.enabled]]experiments.enabled::
@@ -3823,8 +3862,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.
 
@@ -3833,6 +3877,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::
@@ -5634,10 +5683,10 @@
 [[protocol.version]]protocol.version::
 +
 If set, the server will accept requests from a client attempting to communicate
-using the specified protocol version. Otherwise communication falls back to version 0.
-If set in file `etc/jgit.config` this option will be used for all repositories of
-the site. It can be overridden for a given repository by configuring a different
-value in the repository's `config` file.
+using the specified protocol version. Default is `2`. If set in file
+`etc/jgit.config` this option will be used for all repositories of the site.
+It can be overridden for a given repository by configuring a different value in
+the repository's `config` file.
 +
 Supported versions:
 0:: the original wire protocol.
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index a3b9d0b..56d26dc 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -283,6 +283,22 @@
 sticky approvals, reducing turn-around for trivial cleanups prior to
 submitting a change. Defaults to false.
 
+[[label_copyAllScoresIfListOfFilesDidNotChange]]
+=== `label.Label-Name.copyAllScoresIfListOfFilesDidNotChange`
+
+This policy is useful if you don't want to trigger CI or human
+verification again if the list of files didn't change.
+
+If true, all scores for the label are copied forward when a new
+patch-set is uploaded that has the same list of files as the previous
+patch-set.
+
+Renames are considered the same file when computing whether new files
+were added or old files were deleted. Hence, if there are only renames,
+scores will still be copied over.
+
+Defaults to false.
+
 [[label_copyAllScoresOnMergeFirstParentUpdate]]
 === `label.Label-Name.copyAllScoresOnMergeFirstParentUpdate`
 
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index 6492b0b5..8197550 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -281,6 +281,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
 
@@ -358,6 +370,11 @@
 * server
 * ssh
 
+Bazel itself supports a multitude of ways to
+link:https://docs.bazel.build/versions/master/guide.html#specifying-targets-to-build[specify targets,role=external,window=_blank]
+for fine-grained test selection that can be combined with many of the examples
+above.
+
 [[elasticsearch]]
 === Elasticsearch
 
diff --git a/Documentation/dev-contributing.txt b/Documentation/dev-contributing.txt
index 477641b..01857da 100644
--- a/Documentation/dev-contributing.txt
+++ b/Documentation/dev-contributing.txt
@@ -113,6 +113,11 @@
 nags and pester you if you haven't replied or made a fix, so it helps
 them know if you missed it or decided against it.
 
+Features or API extensions, even if they are small, will incur
+long-time maintenance and support burden, so they should be left
+pending for at least 24 hours to give maintainers in all timezones a
+chance to evaluate.
+
 [[design-driven-contribution-process]]
 === Design-driven Contribution Process
 
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index a68c38b..159e2fc 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
@@ -978,17 +1007,9 @@
 Runtime exceptions generated by the implementors of ChangePluginDefinedInfoFactory
 are encapsulated in PluginDefinedInfo objects which are part of SSH/REST query output.
 
-==== ChangeAttributeFactory
-
-Alternatively, there is also `ChangeAttributeFactory` which takes in one single
-`ChangeData` at a time. `ChangePluginDefinedInfoFactory` should be preferred
-over this as it handles many changes at once which also decreases the round-trip
-time for queries resulting in performance increase for bulk queries.
-
-Implementors of the `ChangePluginDefinedInfoFactory` and `ChangeAttributeFactory`
-interfaces should check whether they need to contribute to the
-link:#change-etag-computation[change ETag computation] to prevent callers using
-ETags from potentially seeing outdated plugin attributes.
+Implementors of the `ChangePluginDefinedInfoFactory` interface should check whether
+they need to contribute to the link:#change-etag-computation[change ETag computation]
+to prevent callers using ETags from potentially seeing outdated plugin attributes.
 
 [[simple-configuration]]
 == Simple Configuration in `gerrit.config`
@@ -1424,6 +1445,19 @@
   [...]
 ----
 
+[post_review_extensions]
+== Post Review Extensions
+
+By implementing the `com.google.gerrit.server.restapi.change.OnPostReview`
+interface plugins can extend the change message that is being posted when the
+[post review](rest-api-changes.html#set-review) REST endpoint is invoked.
+
+This is useful if certain approvals have a special meaning (e.g. custom logic
+that is implemented in Prolog submit rules, signal for triggering an action
+like running CI etc.), as it allows the plugin to tell users about this meaning
+in the change message. This makes the effect of a given approval more
+transparent to the user. 
+
 [[ui_extension]]
 == UI Extension
 
@@ -2212,7 +2246,8 @@
   private String imageUrl = "http://placehold.it/16x16.gif";
 
   @Override
-  public WebLinkInfo getPatchSetWebLink(String projectName, String commit) {
+  public WebLinkInfo getPatchSetWebLink(String projectName, String commit,
+   String commitMessage, String branchName) {
     return new WebLinkInfo(name,
         imageUrl,
         String.format(placeHolderUrlProjectCommit, project, commit),
@@ -2427,6 +2462,32 @@
 Macros that start with `\` such as `\@KEEP@` will render as `@KEEP@`
 even if there is an expansion for `KEEP` in the future.
 
+Documentation should typically contain the following content:
+
+[width="100%",options="header"]
+|===================================================
+|File                                           | Content
+|`README.md`                                    | Home page of the plugin when browsing its source code on Git
+|`LICENSE`                                      | Open-source license
+|`resources/Documentation/about.md`             | Overview of the plugin and its purpose
+|`resources/Documentation/config.md`            | Plugin configuration settings and sample configs
+|`resources/Documentation/build.md`             | How to build the plugin
+|`resources/Documentation/cmd-<command>.md`     | SSH commands
+|`resources/Documentation/rest-api-<api>.md`    | REST API
+|`resources/Documentation/servlet-<servlet>.md` | HTTP Servlets
+|===================================================
+
+The documentation under resources/Documentation may contain macro that
+will be included and expanded by Gerrit once the plugin is loaded.
+
+The files in the root directory are not included in the plugin package
+and must not have any macro for expansion. It may also collect
+additional information that would make the plugin more discoverable, such as
+a more user-friendly description of its use-cases.
+
+The documentation can also include images that can help understanding more
+visually how the plugin can interact with the other Gerrit components.
+
 [[auto-index]]
 === Automatic Index
 
diff --git a/Documentation/dev-processes.txt b/Documentation/dev-processes.txt
index 5828cef..68e56ba 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
 
@@ -262,6 +271,15 @@
 It's also possible that the ESC decides that an issue is not a security issue
 and the embargo is lifted immediately.
 
+. Filing a CVE
++
+For every security issue a CVE that describes the issue and lists the affected
+releases should be filed. Filing a CVE can be done by any maintainer that works
+for an organization that can request CVE numbers (e.g. Googlers). The CVE
+number must be included in the release notes. The CVE itself is only made
+public after fixed released have been published and the embargo has been
+lifted.
+
 . Implementation of the security fix:
 +
 To keep the embargo intact, security fixes cannot be developed and reviewed in
@@ -307,6 +325,8 @@
 This ends the embargo and any issue that discusses the security vulnerability
 should be made public.
 
+. Publish the CVE
+
 . Follow-Up
 +
 The ESC should discuss if there are any learnings from the security
diff --git a/Documentation/dev-rest-api.txt b/Documentation/dev-rest-api.txt
index fec9c97..a28e230 100644
--- a/Documentation/dev-rest-api.txt
+++ b/Documentation/dev-rest-api.txt
@@ -50,6 +50,11 @@
  curl -X PUT --user john:2LlAB3K9B0PF --data-binary @project-desc.txt --header "Content-Type: application/json; charset=UTF-8" http://localhost:8080/a/projects/myproject/description
 ----
 
+[[pretty-json]]
+=== Pretty JSON
+
+By default any JSON in responses is compacted. To get pretty-printed JSON add `pp=1` to the request.
+
 === Authentication
 
 To test APIs that require authentication, the username and password must be specified on
diff --git a/Documentation/images/user-porting-comments-original-comment.png b/Documentation/images/user-porting-comments-original-comment.png
new file mode 100644
index 0000000..f8a62ee
--- /dev/null
+++ b/Documentation/images/user-porting-comments-original-comment.png
Binary files differ
diff --git a/Documentation/images/user-porting-comments-ported-comment.png b/Documentation/images/user-porting-comments-ported-comment.png
new file mode 100644
index 0000000..4e4a1b4
--- /dev/null
+++ b/Documentation/images/user-porting-comments-ported-comment.png
Binary files differ
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
index abfc878..7ef9473 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -247,48 +247,47 @@
 ----
 
 
-[[isarray]]
-isarray
+[[DefinitelyTyped]]
+DefinitelyTyped
 
-* isarray
+* @types/resize-observer-browser
 
-[[isarray_license]]
+[[DefinitelyTyped_license]]
 ----
-(MIT)
+    MIT License
 
-Copyright (c) 2013 Julian Gruber <julian@juliangruber.com>;
+    Copyright (c) Microsoft Corporation.
 
-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.
+    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
+[[Polymer-2014]]
+Polymer-2014
 
-* @webcomponents/webcomponentsjs
-* polymer-bridges
-* polymer-resin
+* @polymer/paper-ripple
+* @polymer/paper-styles
 
-[[Polymer-2018_license]]
+[[Polymer-2014_license]]
 ----
-Copyright (c) 2018 The Polymer Project Authors. All rights reserved.
+Copyright (c) 2014 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
@@ -326,266 +325,6 @@
 ----
 
 
-[[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.
-
-----
-
-
-[[shadow-selection-polyfill]]
-shadow-selection-polyfill
-
-* shadow-selection-polyfill
-
-[[shadow-selection-polyfill_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 [yyyy] [name of copyright owner]
-
-   Licensed under the Apache License, Version 2.0 (the "License");
-   you may not use this file except in compliance with the License.
-   You may obtain a copy of the License at
-
-       http://www.apache.org/licenses/LICENSE-2.0
-
-   Unless required by applicable law or agreed to in writing, software
-   distributed under the License is distributed on an "AS IS" BASIS,
-   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-   See the License for the specific language governing permissions and
-   limitations under the License.
-
-----
-
-
 [[Polymer-2015]]
 Polymer-2015
 
@@ -673,48 +412,16 @@
 ----
 
 
-[[ba-linkify]]
-ba-linkify
+[[Polymer-2017]]
+Polymer-2017
 
-* ba-linkify
+* @polymer/decorators
+* @polymer/polymer
+* @webcomponents/shadycss
 
-[[ba-linkify_license]]
+[[Polymer-2017_license]]
 ----
-Copyright (c) 2009 "Cowboy" Ben Alman
-
-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-2014]]
-Polymer-2014
-
-* @polymer/paper-ripple
-* @polymer/paper-styles
-
-[[Polymer-2014_license]]
-----
-Copyright (c) 2014 The Polymer Project Authors. All rights reserved.
+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
@@ -752,6 +459,86 @@
 ----
 
 
+[[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
+
+* ba-linkify
+
+[[ba-linkify_license]]
+----
+Copyright (c) 2009 "Cowboy" Ben Alman
+
+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.
+
+----
+
+
 [[font-roboto-local-fonts-roboto]]
 font-roboto-local-fonts-roboto
 
@@ -1205,34 +992,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.
 
 ----
 
@@ -1269,3 +1134,484 @@
 
 ----
 
+
+[[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.
+ 
+
+----
+
+
+[[shadow-selection-polyfill]]
+shadow-selection-polyfill
+
+* shadow-selection-polyfill
+
+[[shadow-selection-polyfill_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 [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT 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 cc67f91..5f0fb65 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -44,10 +44,12 @@
 
 * auto:auto-value
 * auto:auto-value-annotations
+* auto:auto-value-gson
 * commons:codec
 * commons:compress
 * commons:dbcp
 * commons:lang
+* commons:lang3
 * commons:net
 * commons:pool
 * commons:validator
@@ -923,6 +925,18 @@
 ----
 
 
+[[PublicDomain]]
+PublicDomain
+
+* guice:aopalliance
+
+[[PublicDomain_license]]
+----
+This software has been placed in the public domain by its author(s).
+
+----
+
+
 [[antlr]]
 antlr
 
@@ -3190,48 +3204,47 @@
 ----
 
 
-[[isarray]]
-isarray
+[[DefinitelyTyped]]
+DefinitelyTyped
 
-* isarray
+* @types/resize-observer-browser
 
-[[isarray_license]]
+[[DefinitelyTyped_license]]
 ----
-(MIT)
+    MIT License
 
-Copyright (c) 2013 Julian Gruber <julian@juliangruber.com>;
+    Copyright (c) Microsoft Corporation.
 
-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.
+    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
+[[Polymer-2014]]
+Polymer-2014
 
-* @webcomponents/webcomponentsjs
-* polymer-bridges
-* polymer-resin
+* @polymer/paper-ripple
+* @polymer/paper-styles
 
-[[Polymer-2018_license]]
+[[Polymer-2014_license]]
 ----
-Copyright (c) 2018 The Polymer Project Authors. All rights reserved.
+Copyright (c) 2014 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
@@ -3269,266 +3282,6 @@
 ----
 
 
-[[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.
-
-----
-
-
-[[shadow-selection-polyfill]]
-shadow-selection-polyfill
-
-* shadow-selection-polyfill
-
-[[shadow-selection-polyfill_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 [yyyy] [name of copyright owner]
-
-   Licensed under the Apache License, Version 2.0 (the "License");
-   you may not use this file except in compliance with the License.
-   You may obtain a copy of the License at
-
-       http://www.apache.org/licenses/LICENSE-2.0
-
-   Unless required by applicable law or agreed to in writing, software
-   distributed under the License is distributed on an "AS IS" BASIS,
-   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-   See the License for the specific language governing permissions and
-   limitations under the License.
-
-----
-
-
 [[Polymer-2015]]
 Polymer-2015
 
@@ -3616,48 +3369,16 @@
 ----
 
 
-[[ba-linkify]]
-ba-linkify
+[[Polymer-2017]]
+Polymer-2017
 
-* ba-linkify
+* @polymer/decorators
+* @polymer/polymer
+* @webcomponents/shadycss
 
-[[ba-linkify_license]]
+[[Polymer-2017_license]]
 ----
-Copyright (c) 2009 "Cowboy" Ben Alman
-
-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-2014]]
-Polymer-2014
-
-* @polymer/paper-ripple
-* @polymer/paper-styles
-
-[[Polymer-2014_license]]
-----
-Copyright (c) 2014 The Polymer Project Authors. All rights reserved.
+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
@@ -3695,6 +3416,86 @@
 ----
 
 
+[[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
+
+* ba-linkify
+
+[[ba-linkify_license]]
+----
+Copyright (c) 2009 "Cowboy" Ben Alman
+
+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.
+
+----
+
+
 [[font-roboto-local-fonts-roboto]]
 font-roboto-local-fonts-roboto
 
@@ -4148,34 +3949,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.
 
 ----
 
@@ -4213,6 +4092,487 @@
 ----
 
 
+[[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.
+ 
+
+----
+
+
+[[shadow-selection-polyfill]]
+shadow-selection-polyfill
+
+* shadow-selection-polyfill
+
+[[shadow-selection-polyfill_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 [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+----
+
+
+[[tslib]]
+tslib
+
+* tslib
+
+[[tslib_license]]
+----
+Copyright (c) Microsoft Corporation.

+

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

+purpose with or without fee is hereby granted.

+

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

+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY

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

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

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

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

+PERFORMANCE OF THIS SOFTWARE.
+
+----
+
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 8a95bab..d3bea00 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -76,6 +76,12 @@
 * `change/submit_rule_evaluation`: Latency for evaluating submit rules on a change.
 * `change/submit_type_evaluation`: Latency for evaluating the submit type on a change.
 
+=== Comments
+
+* `ported_comments/as_patchset_level`: Total number of comments ported as patchset-level comments.
+* `ported_comments/as_file_level`: Total number of comments ported as file-level comments.
+* `ported_comments/as_range_comments`: Total number of comments having line/range values in the ported patchset.
+
 === HTTP
 
 ==== Jetty
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-access.txt b/Documentation/rest-api-access.txt
index 6664aa2..45a39d8 100644
--- a/Documentation/rest-api-access.txt
+++ b/Documentation/rest-api-access.txt
@@ -406,14 +406,15 @@
 |`config_visible`     |not set if `false`|
 Whether the calling user can see the `refs/meta/config` branch of the
 project.
-|`groups`            ||A map of group UUID to
+|`groups`             ||A map of group UUID to
 link:rest-api-groups.html#group-info[GroupInfo] objects, with names and
 URLs for the group UUIDs used in the `local` map.
 This will include names for groups that might
 be invisible to the caller.
-|`configWebLinks`    ||
-A list of URLs that display the history of the configuration file
-governing this project's access rights.
+|`config_web_links`   |optional|
+Links to the history of the configuration file governing this project's access
+rights as list of link:rest-api-changes.html#web-link-info[WebLinkInfo]
+entities.
 |==================================
 
 
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 2a59d0c..c8d58a7 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -2816,6 +2816,8 @@
 The base which should be pre-selected in the 'Diff Against' drop-down
 list when the change screen is opened for a merge commit.
 Allowed values are `AUTO_MERGE` and `FIRST_PARENT`.
+|`disable_keyboard_shortcuts`     |not set if `false`|
+Whether to disable all keyboard shortcuts.
 |`publish_comments_on_push`     |not set if `false`|
 Whether to link:user-upload.html#publish-comments[publish draft comments] on
 push by default.
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 12f616a..3a5fb96 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -14,8 +14,11 @@
 'POST /changes/'
 --
 
-The change input link:#change-input[ChangeInput] entity must be provided in the
-request body.
+The change input link:#change-input[ChangeInput] entity must be
+provided in the request body. It is not allowed to create changes
+under `refs/tags/` or Gerrit internal ref namespaces such as
+`refs/changes/`, `refs/meta/external-ids/`, and `refs/users/`. The
+request would fail with `400 Bad Request` in this case.
 
 To create a change the calling user must be allowed to
 link:access-control.html#category_push_review[upload to code review].
@@ -561,6 +564,12 @@
   }
 ----
 
+Historical state of the change can be retrieved by specifying the
+`meta=SHA1` parameter. This will use a historical NoteDb snapshot to
+populate ChangeInfo. If the SHA1 is not reachable as a NoteDb state,
+status code 412 is returned.
+
+
 [[get-change-detail]]
 === Get Change Detail
 --
@@ -1394,6 +1403,8 @@
 
 The destination branch must be provided in the request body inside a
 link:#move-input[MoveInput] entity.
+Only veto votes that are blocking the change from submission are moved to
+the destination branch by default.
 
 .Request
 ----
@@ -2048,10 +2059,14 @@
 comments for each path are sorted by patch set number. Each comment has
 the `patch_set` and `author` fields set.
 
-If the `enable_context` request parameter is set to true, the comment entries
+If the `enable-context` request parameter is set to true, the comment entries
 will contain a list of link:#context-line[ContextLine] containing the lines of
 the source file where the comment was written.
 
+The `context-padding` request parameter can be used to specify an extra number
+of context lines to be added before and after the comment range. This parameter
+only works if `enable-context` is set to true.
+
 .Request
 ----
   GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/comments HTTP/1.0
@@ -5143,6 +5158,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
@@ -5944,6 +5961,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
@@ -6454,6 +6473,8 @@
 Only set if link:#current-revision[the current revision] is requested
 (in which case it will only contain a key for the current revision) or
 if link:#all-revisions[all revisions] are requested.
+|`meta_rev_id`           |optional|
+The SHA1 of the NoteDb meta ref.
 |`tracking_ids`       |optional|
 A list of link:#tracking-id-info[TrackingIdInfo] entities describing
 references to external tracking systems. Only set if
@@ -6480,8 +6501,12 @@
 The callers must not rely on the format of the submission ID.
 |`cherry_pick_of_change`   |optional|
 The numeric Change-Id of the change that this change was cherry-picked from.
+Only set if the cherry-pick has been done through the Gerrit REST API (and
+not if a cherry-picked commit was pushed).
 |`cherry_pick_of_patch_set`|optional|
 The patchset number of the change that this change was cherry-picked from.
+Only set if the cherry-pick has been done through the Gerrit REST API (and
+not if a cherry-picked commit was pushed).
 |`contains_git_conflicts`  |optional, not set if `false`|
 Whether the change contains conflicts. +
 If `true`, some of the file contents of the change contain git conflict
@@ -6691,7 +6716,7 @@
 this comment applies.
 |`context_lines` |optional|
 A list of link:#context-line[ContextLine] containing the lines of the source
-file where the comment was written. Available only if the "enable_context"
+file where the comment was written. Available only if the "enable-context"
 parameter (see link:#list-change-comments[List Change Comments]) is set.
 
 |===========================
@@ -7356,6 +7381,11 @@
 |`destination_branch`||Destination branch
 |`message`           |optional|
 A message to be posted in this change's comments
+|`keep_all_votes`    |optional, defaults to false|
+By default, only veto votes that are blocking the change from submission are moved to
+the destination branch. Using this option is only allowed for administrators,
+because it can affect the submission behaviour of the change (depending on the label access
+configuration and submissions rules).
 |===========================
 
 [[notify-info]]
@@ -7475,6 +7505,13 @@
 patch set is inferred. +
 Empty string is used for rebasing directly on top of the target branch,
 which effectively breaks dependency towards a parent change.
+|`allow_conflicts`|optional, defaults to false|
+If `true`, the rebase also succeeds if there are conflicts. +
+If there are conflicts the file contents of the rebased patch set contain
+git conflict markers to indicate the conflicts. +
+Callers can find out whether there were conflicts by checking the
+`contains_git_conflicts` field in the returned link:#change-info[ChangeInfo]. +
+If there are conflicts the change is marked as work-in-progress.
 |===========================
 
 [[related-change-and-commit-info]]
@@ -7627,9 +7664,12 @@
 The message to be added as review comment.
 |`tag`                                  |optional|
 Apply this tag to the review comment message, votes, and inline
-comments. Tags may be used by CI or other automated systems to
-distinguish them from human reviews. Votes/comments that contain `tag` with
-'autogenerated:' prefix can be filtered out in the web UI.
+comments. Tags with an 'autogenerated:' prefix may be used by CI or other
+automated systems to distinguish them from human reviews. If another
+message was posted on a newer patchset, but with the same tag, then the older
+message will be hidden in the UI. Suffixes starting with `~` are not considered,
+so `autogenerated:my-ci-system~trigger` and `autogenerated:my-ci-system~result`
+will be considered being the same tag with regards to the hiding rule.
 |`labels`                               |optional|
 The votes that should be added to the revision as a map that maps the
 label names to the voting values.
@@ -7674,7 +7714,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].
@@ -8061,13 +8102,14 @@
 === WebLinkInfo
 The `WebLinkInfo` entity describes a link to an external site.
 
-[options="header",cols="1,6"]
-|======================
-|Field Name|Description
-|`name`    |The link name.
-|`url`     |The link URL.
-|`image_url`|URL to the icon of the link.
-|======================
+[options="header",cols="1,^1,5"]
+|========================
+|Field Name ||Description
+|`name`     ||The link name.
+|`url`      ||The link URL.
+|`image_url`|optional|URL to the icon of the link.
+|`target`   |optional|The target window in which the web link should be opened.
+|========================
 
 [[work-in-progress-input]]
 === WorkInProgressInput
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index a62ed47..a8d9b3d 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -1552,9 +1552,6 @@
 |`allow_blame`        |not set if `false`|
 link:config-gerrit.html#change.allowBlame[Whether blame on side by side diff is
 allowed].
-|`large_change`       ||
-link:config-gerrit.html#change.largeChange[Number of changed lines from
-which on a change is considered as a large change].
 |`reply_label`        ||
 link:config-gerrit.html#change.replyTooltip[Label name for the reply
 button].
@@ -1842,6 +1839,8 @@
 Whether to enable the web UI for editing GPG keys.
 |`report_bug_url`    |optional|
 link:config-gerrit.html#gerrit.reportBugUrl[URL to report bugs].
+|`instance_id`       |optional|
+link:config-gerrit.html#gerrit.instanceId[Short identifier for this Gerrit installation].
 |=================================
 
 [[index-config-info]]
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 92759b6..49bc7e5 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -1702,7 +1702,9 @@
 'GET /projects/link:#project-name[\{project-name\}]/branches/link:#branch-id[\{branch-id\}]'
 --
 
-Retrieves a branch of a project.
+Retrieves a branch of a project. For the "All-Users" repository, the magic
+branch "refs/users/self" is automatically resolved to the user branch of the
+calling user.
 
 .Request
 ----
@@ -1734,7 +1736,9 @@
 Creates a new branch.
 
 In the request body additional data for the branch can be provided as
-link:#branch-input[BranchInput].
+link:#branch-input[BranchInput]. The link:#branch-id[\{branch-id\}] in the URL
+should exactly match with the `ref` field of link:#branch-input[BranchInput], or
+otherwise the request would fail with `400 Bad Request`.
 
 .Request
 ----
@@ -1846,8 +1850,9 @@
 
 Gets whether the source is mergeable with the target branch.
 
-The `source` query parameter is required, which can be anything that could be
-resolved to a commit, see examples of the `source` attribute in
+The `source` query parameter is required, which can be anything that
+could be resolved to a commit, and is visible to the caller. See
+examples of the `source` attribute in
 link:rest-api-changes.html#merge-input[MergeInput].
 
 Also takes an optional parameter `strategy`, which can be `recursive`, `resolve`,
@@ -2699,7 +2704,10 @@
 The integer-valued request parameter `parent` changes the response to return a
 list of the files which are different in this commit compared to the given
 parent commit. This is useful for supporting review of merge commits. The value
-is the 1-based index of the parent's position in the commit object.
+is the 1-based index of the parent's position in the commit object. If the
+value 0 is used for `parent`, the default base commit will be used, which is
+the only parent for commits having one parent or the auto-merge commit
+otherwise.
 
 [[dashboard-endpoints]]
 == Dashboard Endpoints
@@ -3393,6 +3401,8 @@
 |`status`                    ||The HTTP status code for the access.
 200 means success and 403 means denied.
 |`message`                   |optional|A clarifying message if `status` is not 200.
+|`debug_logs`                |optional|
+Debug logs that may help to understand why a permission is denied or allowed.
 |=========================================
 
 [[auto_closeable_changes_check_input]]
@@ -3964,6 +3974,9 @@
 |`copy_all_scores_on_trivial_rebase`|`false` if not set|
 Whether link:config-labels.html#label_copyAllScoresOnTrivialRebase[
 copyAllScoresOnTrivialRebase] is set on the label.
+|`copy_all_scores_if_list_of_files_did_not_change`|`false` if not set|
+Whether link:config-labels.html#label_copyAllScoresIfListOfFilesDidNotChange[
+copyAllScoresIfListOfFilesDidNotChange] is set on the label.
 |`copy_all_scores_on_merge_first_parent_update`|`false` if not set|
 Whether link:config-labels.html#label_copyAllScoresOnMergeFirstParentUpdate[
 copyAllScoresOnMergeFirstParentUpdate] is set on the label.
@@ -4031,6 +4044,9 @@
 |`copy_all_scores_on_trivial_rebase`|optional|
 Whether link:config-labels.html#label_copyAllScoresOnTrivialRebase[
 copyAllScoresOnTrivialRebase] is set on the label.
+|`copy_all_scores_if_list_of_files_did_not_change`|optional|
+Whether link:config-labels.html#label_copyAllScoresIfListOfFilesDidNotChange[
+copyAllScoresIfListOfFilesDidNotChange] is set on the label.
 |`copy_all_scores_on_merge_first_parent_update`|optional|
 Whether link:config-labels.html#label_copyAllScoresOnMergeFirstParentUpdate[
 copyAllScoresOnMergeFirstParentUpdate] is set on the label.
@@ -4194,7 +4210,11 @@
 repository.<name>.defaultSubmitType] is set to a different value.
 |`branches`                  |optional|
 A list of branches that should be initially created. +
-For the branch names the `refs/heads/` prefix can be omitted.
+For the branch names the `refs/heads/` prefix can be omitted. +
+The first entry of the list will be the default branch (ie. the target
+of the `HEAD` symbolic ref). +
+Branches in the Gerrit internal ref space are not allowed, such as
+refs/groups/, refs/changes/, etc...
 |`owners`                    |optional|
 A list of groups that should be assigned as project owner. +
 Each group in the list must be specified as
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-porting-comments.txt b/Documentation/user-porting-comments.txt
new file mode 100644
index 0000000..8b6c005
--- /dev/null
+++ b/Documentation/user-porting-comments.txt
@@ -0,0 +1,37 @@
+#  Porting Comments User Documentation
+
+Report a bug or send feedback using this [Monorail template](https://bugs.chromium.org/p/gerrit/issues/entry?template=Porting+Comments). You can also report a bug through the bug icon in the comment.
+
+Comments in Gerrit are associated with a patchset. When a new patchset is uploaded, the comments are lost since they are not associated with the newer patchset.
+
+image::images/user-porting-comments-original-comment.png["Comment left on Patchset 15", align="center"]
+
+To solve this issue, Gerrit now has “Ported Comments”. These are comments that were left on an older patchset displayed on all the newer patchsets uploaded. For example, a comment left on Patchset 6 will be ported over to Patchset 7, 8 and all subsequent patchsets that are uploaded, not just the latest patchset.
+
+Ported comments are not copies of the comment but the comment simply shown in another place.
+
+Which comments are ported over?
+
+*   Unresolved comments
+*   Unresolved drafts
+*   Resolved drafts
+
+Resolved comments are not ported over.
+
+image::images/user-porting-comments-ported-comment.png["Comment ported over to patchset 16", align="center"]
+
+## Interaction
+
+Ported comments are visually the same as normal comments. They have a link at the top which shows the original patchset of the comment and links to it.
+
+Interacting with the ported comments is exactly the same as interacting with the original comment (again, they are simply the original comment shown in a different location). \
+Marking a ported comment resolved/unresolved will also update the original comment.
+
+
+## Position
+
+Gerrit tries to calculate the position of this comment on the new version of the file and shows the comment on that position for the newer patchset.
+
+It’s not always possible to calculate an appropriate position for a comment. In this case, Gerrit attaches these comments as File Level Comments.
+
+In some exceptional cases (such as the entire file being reverted), there is no appropriate file to associate this comment with. In this case we do not port this comment over. The comment is still present at its original location and visible in the Comments Tab & Change Log.
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index 0c1ec2d..52c282e 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -92,6 +92,18 @@
 format `2006-01-02[ 15:04:05[.890][ -0700]]`; omitting the time defaults
 to 00:00:00 and omitting the timezone defaults to UTC.
 
+[[mergedbefore]]
+mergedbefore:'TIME'::
++
+Changes merged before the given 'TIME'. The matching behaviour is consistent
+with `before:'TIME'`.
+
+[[mergedafter]]
+mergedafter:'TIME'::
++
+Changes merged after the given 'TIME'. The matching behaviour is consistent
+with `after:'TIME'`.
+
 [[change]]
 change:'ID'::
 +
@@ -106,9 +118,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 +137,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]]
@@ -174,6 +190,13 @@
 +
 Changes occurring in projects starting with 'PREFIX'.
 
+[[parentof]]
+parentof:'ID'::
+Changes which are parent to the change specified by 'ID'. Change 'ID' can be
+specified as a legacy numerical 'ID' such as 15183, or a Change-Id that can be
+picked from the commit message. This operator will return immediate parents
+and will not return grand parents or higher level ancestors of the given change.
+
 [[parentproject]]
 parentproject:'PROJECT'::
 +
diff --git a/WORKSPACE b/WORKSPACE
index a8261f8..c24d4f9 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -30,7 +30,7 @@
 load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive", "http_file")
 load("//tools/bzl:maven_jar.bzl", "GERRIT", "MAVEN_LOCAL", "maven_jar")
 load("//plugins:external_plugin_deps.bzl", "external_plugin_deps")
-load("//tools:nongoogle.bzl", "declare_nongoogle_deps")
+load("//tools:nongoogle.bzl", "TESTCONTAINERS_VERSION", "declare_nongoogle_deps")
 
 http_archive(
     name = "bazel_toolchains",
@@ -53,10 +53,10 @@
 
 http_archive(
     name = "com_google_protobuf",
-    sha256 = "71030a04aedf9f612d2991c1c552317038c3c5a2b578ac4745267a45e7037c29",
-    strip_prefix = "protobuf-3.12.3",
+    sha256 = "d0f5f605d0d656007ce6c8b5a82df3037e1d8fe8b121ed42e536f569dec16113",
+    strip_prefix = "protobuf-3.14.0",
     urls = [
-        "https://github.com/protocolbuffers/protobuf/archive/v3.12.3.tar.gz",
+        "https://github.com/protocolbuffers/protobuf/archive/v3.14.0.tar.gz",
     ],
 )
 
@@ -66,17 +66,17 @@
 
 http_archive(
     name = "build_bazel_rules_nodejs",
-    sha256 = "5bf77cc2d13ddf9124f4c1453dd96063774d755d4fc75d922471540d1c9a8ea8",
-    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/2.0.0/rules_nodejs-2.0.0.tar.gz"],
+    sha256 = "fcc6dccb39ca88d481224536eb8f9fa754619676c6163f87aa6af94059b02b12",
+    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/3.2.0/rules_nodejs-3.2.0.tar.gz"],
 )
 
 # Golang support for PolyGerrit local dev server.
 http_archive(
     name = "io_bazel_rules_go",
-    sha256 = "a8d6b1b354d371a646d2f7927319974e0f9e52f73a2452d2b3877118169eb6bb",
+    sha256 = "4d838e2d70b955ef9dd0d0648f673141df1bc1d7ecf5c2d621dcc163f47dd38a",
     urls = [
-        "https://mirror.bazel.build/github.com/bazelbuild/rules_go/releases/download/v0.23.3/rules_go-v0.23.3.tar.gz",
-        "https://github.com/bazelbuild/rules_go/releases/download/v0.23.3/rules_go-v0.23.3.tar.gz",
+        "https://mirror.bazel.build/github.com/bazelbuild/rules_go/releases/download/v0.24.12/rules_go-v0.24.12.tar.gz",
+        "https://github.com/bazelbuild/rules_go/releases/download/v0.24.12/rules_go-v0.24.12.tar.gz",
     ],
 )
 
@@ -88,10 +88,10 @@
 
 http_archive(
     name = "bazel_gazelle",
-    sha256 = "cdb02a887a7187ea4d5a27452311a75ed8637379a1287d8eeb952138ea485f7d",
+    sha256 = "222e49f034ca7a1d1231422cdb67066b885819885c356673cb1f72f748a3c9d4",
     urls = [
-        "https://mirror.bazel.build/github.com/bazelbuild/bazel-gazelle/releases/download/v0.21.1/bazel-gazelle-v0.21.1.tar.gz",
-        "https://github.com/bazelbuild/bazel-gazelle/releases/download/v0.21.1/bazel-gazelle-v0.21.1.tar.gz",
+        "https://mirror.bazel.build/github.com/bazelbuild/bazel-gazelle/releases/download/v0.22.3/bazel-gazelle-v0.22.3.tar.gz",
+        "https://github.com/bazelbuild/bazel-gazelle/releases/download/v0.22.3/bazel-gazelle-v0.22.3.tar.gz",
     ],
 )
 
@@ -139,34 +139,10 @@
     sha1 = "83cd2cd674a217ade95a4bb83a8a14f351f48bd0",
 )
 
-GUICE_VERS = "4.2.3"
-
-GUICE_LIBRARY_SHA256 = "5168f5e7383f978c1b4154ac777b78edd8ac214bb9f9afdb92921c8d156483d3"
-
-http_file(
-    name = "guice-library-no-aop",
-    canonical_id = "guice-library-no-aop-" + GUICE_VERS + ".jar-" + GUICE_LIBRARY_SHA256,
-    downloaded_file_path = "guice-library-no-aop.jar",
-    sha256 = GUICE_LIBRARY_SHA256,
-    urls = [
-        "https://repo1.maven.org/maven2/com/google/inject/guice/" +
-        GUICE_VERS +
-        "/guice-" +
-        GUICE_VERS +
-        "-no_aop.jar",
-    ],
-)
-
 maven_jar(
-    name = "guice-assistedinject",
-    artifact = "com.google.inject.extensions:guice-assistedinject:" + GUICE_VERS,
-    sha1 = "acbfddc556ee9496293ed1df250cc378f331d854",
-)
-
-maven_jar(
-    name = "guice-servlet",
-    artifact = "com.google.inject.extensions:guice-servlet:" + GUICE_VERS,
-    sha1 = "8d6e7e35eac4fb5e7df19c55b3bc23fa51b10a11",
+    name = "aopalliance",
+    artifact = "aopalliance:aopalliance:1.0",
+    sha1 = "0235ba8b489512805ac13a8f9ea77a1ca5ebe3e8",
 )
 
 maven_jar(
@@ -213,14 +189,6 @@
     sha1 = "f645ed69d595b24d4cf8b3fbb64cc505bede8829",
 )
 
-load("//lib:guava.bzl", "GUAVA_BIN_SHA1", "GUAVA_VERSION")
-
-maven_jar(
-    name = "guava",
-    artifact = "com.google.guava:guava:" + GUAVA_VERSION,
-    sha1 = GUAVA_BIN_SHA1,
-)
-
 CAFFEINE_VERS = "2.8.5"
 
 maven_jar(
@@ -401,156 +369,156 @@
     sha1 = "c075db2a3301100cf70c7dced8ecf86b494458a2",
 )
 
-FLEXMARK_VERS = "0.34.18"
+FLEXMARK_VERS = "0.50.42"
 
 maven_jar(
     name = "flexmark",
     artifact = "com.vladsch.flexmark:flexmark:" + FLEXMARK_VERS,
-    sha1 = "65cc1489ef8902023140900a3a7fcce89fba678d",
+    sha1 = "ed537d7bc31883b008cc17d243a691c7efd12a72",
 )
 
 maven_jar(
     name = "flexmark-ext-abbreviation",
     artifact = "com.vladsch.flexmark:flexmark-ext-abbreviation:" + FLEXMARK_VERS,
-    sha1 = "a0384932801e51f16499358dec69a730739aca3f",
+    sha1 = "dc27c3e7abbc8d2cfb154f41c68645c365bb9d22",
 )
 
 maven_jar(
     name = "flexmark-ext-anchorlink",
     artifact = "com.vladsch.flexmark:flexmark-ext-anchorlink:" + FLEXMARK_VERS,
-    sha1 = "6df2e23b5c94a5e46b1956a29179eb783f84ea2f",
+    sha1 = "6a8edb0165f695c9c19b7143a7fbd78c25c3b99c",
 )
 
 maven_jar(
     name = "flexmark-ext-autolink",
     artifact = "com.vladsch.flexmark:flexmark-ext-autolink:" + FLEXMARK_VERS,
-    sha1 = "069f8ff15e5b435cc96b23f31798ce64a7a3f6d3",
+    sha1 = "5da7a4d009ea08ef2d8714cc73e54a992c6d2d9a",
 )
 
 maven_jar(
     name = "flexmark-ext-definition",
     artifact = "com.vladsch.flexmark:flexmark-ext-definition:" + FLEXMARK_VERS,
-    sha1 = "ff177d8970810c05549171e3ce189e2c68b906c0",
+    sha1 = "862d17812654624ed81ce8fc89c5ef819ff45f87",
 )
 
 maven_jar(
     name = "flexmark-ext-emoji",
     artifact = "com.vladsch.flexmark:flexmark-ext-emoji:" + FLEXMARK_VERS,
-    sha1 = "410bf7d8e5b8bc2c4a8cff644d1b2bc7b271a41e",
+    sha1 = "f0d7db64cb546798742b1ffc6db316a33f6acd76",
 )
 
 maven_jar(
     name = "flexmark-ext-escaped-character",
     artifact = "com.vladsch.flexmark:flexmark-ext-escaped-character:" + FLEXMARK_VERS,
-    sha1 = "6f4fb89311b54284a6175341d4a5e280f13b2179",
+    sha1 = "6fd9ab77619df417df949721cb29c45914b326f8",
 )
 
 maven_jar(
     name = "flexmark-ext-footnotes",
     artifact = "com.vladsch.flexmark:flexmark-ext-footnotes:" + FLEXMARK_VERS,
-    sha1 = "35efe7d9aea97b6f36e09c65f748863d14e1cfe4",
+    sha1 = "e36bd69e43147cc6e19c3f55e4b27c0fc5a3d88c",
 )
 
 maven_jar(
     name = "flexmark-ext-gfm-issues",
     artifact = "com.vladsch.flexmark:flexmark-ext-gfm-issues:" + FLEXMARK_VERS,
-    sha1 = "ec1d660102f6a1d0fbe5e57c13b7ff8bae6cff72",
+    sha1 = "5c825dd4e4fa4f7ccbe30dc92d7e35cdcb8a8c24",
 )
 
 maven_jar(
     name = "flexmark-ext-gfm-strikethrough",
     artifact = "com.vladsch.flexmark:flexmark-ext-gfm-strikethrough:" + FLEXMARK_VERS,
-    sha1 = "6060442b742c9b6d4d83d7dd4f0fe477c4686dd2",
+    sha1 = "3256735fd77e7228bf40f7888b4d3dc56787add4",
 )
 
 maven_jar(
     name = "flexmark-ext-gfm-tables",
     artifact = "com.vladsch.flexmark:flexmark-ext-gfm-tables:" + FLEXMARK_VERS,
-    sha1 = "2fe597849e46e02e0c1ea1d472848f74ff261282",
+    sha1 = "62f0efcfb974756940ebe749fd4eb01323babc29",
 )
 
 maven_jar(
     name = "flexmark-ext-gfm-tasklist",
     artifact = "com.vladsch.flexmark:flexmark-ext-gfm-tasklist:" + FLEXMARK_VERS,
-    sha1 = "b3af19ce4efdc980a066c1bf0f5a6cf8c24c487a",
+    sha1 = "76d4971ad9ce02f0e70351ab6bd06ad8e405e40d",
 )
 
 maven_jar(
     name = "flexmark-ext-gfm-users",
     artifact = "com.vladsch.flexmark:flexmark-ext-gfm-users:" + FLEXMARK_VERS,
-    sha1 = "7456c5f7272c195ee953a02ebab4f58374fb23ee",
+    sha1 = "7b0fc7e42e4da508da167fcf8e1cbf9ba7e21147",
 )
 
 maven_jar(
     name = "flexmark-ext-ins",
     artifact = "com.vladsch.flexmark:flexmark-ext-ins:" + FLEXMARK_VERS,
-    sha1 = "13fe1a95a8f3be30b574451cfe8d3d5936fa3e94",
+    sha1 = "9e51809867b9c4db0fb1c29599b4574e3d2a78e9",
 )
 
 maven_jar(
     name = "flexmark-ext-jekyll-front-matter",
     artifact = "com.vladsch.flexmark:flexmark-ext-jekyll-front-matter:" + FLEXMARK_VERS,
-    sha1 = "e146e2bf3a740d6ef06a33a516c4d1f6d3761109",
+    sha1 = "44eb6dbb33b3831d3b40af938ddcd99c9c16a654",
 )
 
 maven_jar(
     name = "flexmark-ext-superscript",
     artifact = "com.vladsch.flexmark:flexmark-ext-superscript:" + FLEXMARK_VERS,
-    sha1 = "02541211e8e4a6c89ce0a68b07b656d8a19ac282",
+    sha1 = "35815b8cb91000344d1fe5df21cacde8553d2994",
 )
 
 maven_jar(
     name = "flexmark-ext-tables",
     artifact = "com.vladsch.flexmark:flexmark-ext-tables:" + FLEXMARK_VERS,
-    sha1 = "775d9587de71fd50573f32eee98ab039b4dcc219",
+    sha1 = "f6768e98c7210b79d5e8bab76fff27eec6db51e6",
 )
 
 maven_jar(
     name = "flexmark-ext-toc",
     artifact = "com.vladsch.flexmark:flexmark-ext-toc:" + FLEXMARK_VERS,
-    sha1 = "85b75fe1ebe24c92b9d137bcbc51d232845b6077",
+    sha1 = "1968d038fc6c8156f244f5a7eecb34e7e2f33705",
 )
 
 maven_jar(
     name = "flexmark-ext-typographic",
     artifact = "com.vladsch.flexmark:flexmark-ext-typographic:" + FLEXMARK_VERS,
-    sha1 = "c1bf0539de37d83aa05954b442f929e204cd89db",
+    sha1 = "6549b9862b61c4434a855a733237103df9162849",
 )
 
 maven_jar(
     name = "flexmark-ext-wikilink",
     artifact = "com.vladsch.flexmark:flexmark-ext-wikilink:" + FLEXMARK_VERS,
-    sha1 = "400b23b9a4e0c008af0d779f909ee357628be39d",
+    sha1 = "e105b09dd35aab6e6f5c54dfe062ee59bd6f786a",
 )
 
 maven_jar(
     name = "flexmark-ext-yaml-front-matter",
     artifact = "com.vladsch.flexmark:flexmark-ext-yaml-front-matter:" + FLEXMARK_VERS,
-    sha1 = "491f815285a8e16db1e906f3789a94a8a9836fa6",
+    sha1 = "b2d3a1e7f3985841062e8d3203617e29c6c21b52",
 )
 
 maven_jar(
     name = "flexmark-formatter",
     artifact = "com.vladsch.flexmark:flexmark-formatter:" + FLEXMARK_VERS,
-    sha1 = "d46308006800d243727100ca0f17e6837070fd48",
+    sha1 = "a50c6cb10f6d623fc4354a572c583de1372d217f",
 )
 
 maven_jar(
     name = "flexmark-html-parser",
     artifact = "com.vladsch.flexmark:flexmark-html-parser:" + FLEXMARK_VERS,
-    sha1 = "fece2e646d11b6a77fc611b4bd3eb1fb8a635c87",
+    sha1 = "46c075f30017e131c1ada8538f1d8eacf652b044",
 )
 
 maven_jar(
     name = "flexmark-profile-pegdown",
     artifact = "com.vladsch.flexmark:flexmark-profile-pegdown:" + FLEXMARK_VERS,
-    sha1 = "297f723bb51286eaa7029558fac87d819643d577",
+    sha1 = "d9aafd47629959cbeddd731f327ae090fc92b60f",
 )
 
 maven_jar(
     name = "flexmark-util",
     artifact = "com.vladsch.flexmark:flexmark-util:" + FLEXMARK_VERS,
-    sha1 = "31e2e1fbe8273d7c913506eafeb06b1a7badb062",
+    sha1 = "417a9821d5d80ddacbfecadc6843ae7b259d5112",
 )
 
 # Transitive dependency of flexmark and gitiles
@@ -642,6 +610,38 @@
     sha1 = "eff48ed53995db2dadf0456426cc1f8700136f86",
 )
 
+AUTO_VALUE_GSON_VERSION = "1.3.0"
+
+maven_jar(
+    name = "auto-value-gson-runtime",
+    artifact = "com.ryanharter.auto.value:auto-value-gson-runtime:" + AUTO_VALUE_GSON_VERSION,
+    sha1 = "a69a9db5868bb039bd80f60661a771b643eaba59",
+)
+
+maven_jar(
+    name = "auto-value-gson-extension",
+    artifact = "com.ryanharter.auto.value:auto-value-gson-extension:" + AUTO_VALUE_GSON_VERSION,
+    sha1 = "6a61236d17b58b05e32b4c532bcb348280d2212b",
+)
+
+maven_jar(
+    name = "auto-value-gson-factory",
+    artifact = "com.ryanharter.auto.value:auto-value-gson-factory:" + AUTO_VALUE_GSON_VERSION,
+    sha1 = "b1f01918c0d6cb1f5482500e6b9e62589334dbb0",
+)
+
+maven_jar(
+    name = "javapoet",
+    artifact = "com.squareup:javapoet:1.13.0",
+    sha1 = "d6562d385049f35eb50403fa86bb11cce76b866a",
+)
+
+maven_jar(
+    name = "autotransient",
+    artifact = "io.sweers.autotransient:autotransient:1.0.0",
+    sha1 = "38b1c630b8e76560221622289f37be40105abb3d",
+)
+
 declare_nongoogle_deps()
 
 LUCENE_VERS = "6.6.5"
@@ -757,13 +757,6 @@
     sha1 = "b8ba1c1eb8b2e45cfd465d01218c6060e887572e",
 )
 
-# Keep this version of Soy synchronized with the version used in Gitiles.
-maven_jar(
-    name = "soy",
-    artifact = "com.google.template:soy:2019-10-08",
-    sha1 = "4518bf8bac2dbbed684849bc209c39c4cb546237",
-)
-
 maven_jar(
     name = "html-types",
     artifact = "com.google.common.html.types:types:1.0.8",
@@ -832,12 +825,6 @@
 # Test-only dependencies below.
 
 maven_jar(
-    name = "jimfs",
-    artifact = "com.google.jimfs:jimfs:1.1",
-    sha1 = "8fbd0579dc68aba6186935cc1bee21d2f3e7ec1c",
-)
-
-maven_jar(
     name = "junit",
     artifact = "junit:junit:4.12",
     sha1 = "2973d150c0dc1fefe998f834810d68f278ea58ec",
@@ -849,87 +836,61 @@
     sha1 = "42a25dc3219429f0e5d060061f71acb49bf010a0",
 )
 
-TRUTH_VERS = "1.1"
-
-maven_jar(
-    name = "truth",
-    artifact = "com.google.truth:truth:" + TRUTH_VERS,
-    sha1 = "6a096a16646559c24397b03f797d0c9d75ee8720",
-)
-
-maven_jar(
-    name = "truth-java8-extension",
-    artifact = "com.google.truth.extensions:truth-java8-extension:" + TRUTH_VERS,
-    sha1 = "258db6eb8df61832c5c059ed2bc2e1c88683e92f",
-)
-
-maven_jar(
-    name = "truth-liteproto-extension",
-    artifact = "com.google.truth.extensions:truth-liteproto-extension:" + TRUTH_VERS,
-    sha1 = "bf65afa13aa03330e739bcaa5d795fe0f10fbf20",
-)
-
-maven_jar(
-    name = "truth-proto-extension",
-    artifact = "com.google.truth.extensions:truth-proto-extension:" + TRUTH_VERS,
-    sha1 = "64cba89cf87c1d84cb8c81d06f0b9c482f10b4dc",
-)
-
 maven_jar(
     name = "diffutils",
     artifact = "com.googlecode.java-diff-utils:diffutils:1.3.0",
     sha1 = "7e060dd5b19431e6d198e91ff670644372f60fbd",
 )
 
-JETTY_VERS = "9.4.35.v20201120"
+JETTY_VERS = "9.4.36.v20210114"
 
 maven_jar(
     name = "jetty-servlet",
     artifact = "org.eclipse.jetty:jetty-servlet:" + JETTY_VERS,
-    sha1 = "3e61bcb471e1bfc545ce866cbbe33c3aedeec9b1",
+    sha1 = "b189e52a5ee55ae172e4e99e29c5c314f5daf4b9",
 )
 
 maven_jar(
     name = "jetty-security",
     artifact = "org.eclipse.jetty:jetty-security:" + JETTY_VERS,
-    sha1 = "80dc2f422789c78315de76d289b7a5b36c3232d5",
+    sha1 = "42030d6ed7dfc0f75818cde0adcf738efc477574",
 )
 
 maven_jar(
     name = "jetty-server",
     artifact = "org.eclipse.jetty:jetty-server:" + JETTY_VERS,
-    sha1 = "513502352fd689d4730b2935421b990ada8cc818",
+    sha1 = "88a7d342974aadca658e7386e8d0fcc5c0788f41",
 )
 
 maven_jar(
     name = "jetty-jmx",
     artifact = "org.eclipse.jetty:jetty-jmx:" + JETTY_VERS,
-    sha1 = "38812031940a466d626ab5d9bbbd9d5d39e9f735",
+    sha1 = "bb3847eabe085832aeaedd30e872b40931632e54",
 )
 
 maven_jar(
     name = "jetty-http",
     artifact = "org.eclipse.jetty:jetty-http:" + JETTY_VERS,
-    sha1 = "45d35131a35a1e76991682174421e8cdf765fb9f",
+    sha1 = "1eee89a55e04ff94df0f85d95200fc48acb43d86",
 )
 
 maven_jar(
     name = "jetty-io",
     artifact = "org.eclipse.jetty:jetty-io:" + JETTY_VERS,
-    sha1 = "eb9460700b99b71ecd82a53697f5ff99f69b9e1c",
+    sha1 = "84a8faf9031eb45a5a2ddb7681e22c483d81ab3a",
 )
 
 maven_jar(
     name = "jetty-util",
     artifact = "org.eclipse.jetty:jetty-util:" + JETTY_VERS,
-    sha1 = "ef61b83f9715c3b5355b633d9f01d2834f908ece",
+    sha1 = "925257fbcca6b501a25252c7447dbedb021f7404",
 )
 
 maven_jar(
     name = "jetty-util-ajax",
     artifact = "org.eclipse.jetty:jetty-util-ajax:" + JETTY_VERS,
-    sha1 = "ebbb43912c6423bedb3458e44aee28eeb4d66f27",
-    src_sha1 = "b3acea974a17493afb125a9dfbe783870ce1d2f9",
+    sha1 = "2f478130c21787073facb64d7242e06f94980c60",
+    src_sha1 = "7153d7ca38878d971fd90992c303bb7719ba7a21",
 )
 
 maven_jar(
@@ -980,6 +941,7 @@
 
 yarn_install(
     name = "npm",
+    frozen_lockfile = False,
     package_json = "//:package.json",
     yarn_lock = "//:yarn.lock",
 )
@@ -987,18 +949,21 @@
 yarn_install(
     name = "ui_npm",
     args = ["--prod"],
+    frozen_lockfile = False,
     package_json = "//:polygerrit-ui/app/package.json",
     yarn_lock = "//:polygerrit-ui/app/yarn.lock",
 )
 
 yarn_install(
     name = "ui_dev_npm",
+    frozen_lockfile = False,
     package_json = "//:polygerrit-ui/package.json",
     yarn_lock = "//:polygerrit-ui/yarn.lock",
 )
 
 yarn_install(
     name = "tools_npm",
+    frozen_lockfile = False,
     package_json = "//:tools/node_tools/package.json",
     yarn_lock = "//:tools/node_tools/yarn.lock",
 )
@@ -1006,6 +971,7 @@
 yarn_install(
     name = "plugins_npm",
     args = ["--prod"],
+    frozen_lockfile = False,
     package_json = "//:plugins/package.json",
     yarn_lock = "//:plugins/yarn.lock",
 )
@@ -1202,3 +1168,19 @@
 )
 
 external_plugin_deps()
+
+# When upgrading elasticsearch-rest-client, also upgrade httpcore-nio
+# and httpasyncclient as necessary in tools/nongoogle.bzl. Consider
+# also the other org.apache.httpcomponents dependencies in
+# WORKSPACE.
+maven_jar(
+    name = "elasticsearch-rest-client",
+    artifact = "org.elasticsearch.client:elasticsearch-rest-client:7.8.1",
+    sha1 = "59feefe006a96a39f83b0dfb6780847e06c1d0a8",
+)
+
+maven_jar(
+    name = "testcontainers-elasticsearch",
+    artifact = "org.testcontainers:elasticsearch:" + TESTCONTAINERS_VERSION,
+    sha1 = "6b778a270b7529fcb9b7a6f62f3ae9d38544ce2f",
+)
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 4ab5d51..5b27088 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,57 @@
     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(i - 1), i -> fileNames.get(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/AbstractDynamicOptionsTest.java b/java/com/google/gerrit/acceptance/AbstractDynamicOptionsTest.java
new file mode 100644
index 0000000..a4ed80a
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/AbstractDynamicOptionsTest.java
@@ -0,0 +1,117 @@
+// 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 static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+
+import com.google.common.collect.Lists;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.json.OutputFormat;
+import com.google.gerrit.server.DynamicOptions;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.CommandModule;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.gson.reflect.TypeToken;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import java.io.BufferedWriter;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.util.Collections;
+import java.util.List;
+
+public class AbstractDynamicOptionsTest extends AbstractDaemonTest {
+  protected static final String LS_SAMPLES = "ls-samples";
+
+  protected interface Bean {
+    void setSamples(List<String> samples);
+  }
+
+  protected static class ListSamples implements Bean, DynamicOptions.BeanReceiver {
+    protected List<String> samples = Collections.emptyList();
+
+    @Override
+    public void setSamples(List<String> samples) {
+      this.samples = samples;
+    }
+
+    public void display(OutputStream displayOutputStream) throws Exception {
+      PrintWriter stdout =
+          new PrintWriter(new BufferedWriter(new OutputStreamWriter(displayOutputStream, "UTF-8")));
+      try {
+        OutputFormat.JSON
+            .newGson()
+            .toJson(samples, new TypeToken<List<String>>() {}.getType(), stdout);
+        stdout.print('\n');
+      } finally {
+        stdout.flush();
+      }
+    }
+
+    @Override
+    public void setDynamicBean(String plugin, DynamicOptions.DynamicBean dynamicBean) {}
+  }
+
+  @CommandMetaData(name = LS_SAMPLES, runsAt = MASTER_OR_SLAVE)
+  protected static class ListSamplesCommand extends SshCommand {
+    @Inject private ListSamples impl;
+
+    @Override
+    protected void run() throws Exception {
+      impl.display(out);
+    }
+
+    @Override
+    protected void parseCommandLine(DynamicOptions pluginOptions) throws UnloggedFailure {
+      parseCommandLine(impl, pluginOptions);
+    }
+  }
+
+  public static class PluginOneSshModule extends CommandModule {
+    @Override
+    public void configure() {
+      command(LS_SAMPLES).to(ListSamplesCommand.class);
+    }
+  }
+
+  protected static class ListSamplesOptions implements DynamicOptions.BeanParseListener {
+    @Override
+    public void onBeanParseStart(String plugin, Object bean) {
+      ((Bean) bean).setSamples(Lists.newArrayList("sample1", "sample2"));
+    }
+
+    @Override
+    public void onBeanParseEnd(String plugin, Object bean) {}
+  }
+
+  protected static class PluginTwoModule extends AbstractModule {
+    @Override
+    public void configure() {
+      bind(DynamicOptions.DynamicBean.class)
+          .annotatedWith(
+              Exports.named("com.google.gerrit.acceptance.AbstractDynamicOptionsTest.ListSamples"))
+          .to(ListSamplesOptionsClassNameProvider.class);
+    }
+  }
+
+  protected static class ListSamplesOptionsClassNameProvider
+      implements DynamicOptions.ClassNameProvider {
+    @Override
+    public String getClassName() {
+      return "com.google.gerrit.acceptance.AbstractDynamicOptionsTest$ListSamplesOptions";
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/AbstractLifecycleListenersTest.java b/java/com/google/gerrit/acceptance/AbstractLifecycleListenersTest.java
new file mode 100644
index 0000000..6acf486
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/AbstractLifecycleListenersTest.java
@@ -0,0 +1,109 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.server.DynamicOptions;
+import com.google.gerrit.server.restapi.change.QueryChanges;
+import com.google.gerrit.sshd.commands.Query;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.google.inject.internal.UniqueAnnotations;
+import java.util.Collections;
+import org.kohsuke.args4j.Option;
+
+public class AbstractLifecycleListenersTest extends AbstractDaemonTest {
+  protected static class SimpleModule extends AbstractModule {
+    @Override
+    public void configure() {
+      bind(com.google.gerrit.server.DynamicOptions.DynamicBean.class)
+          .annotatedWith(Exports.named(Query.class))
+          .to(MyClassNameProvider.class);
+      bind(DynamicOptions.DynamicBean.class)
+          .annotatedWith(Exports.named(QueryChanges.class))
+          .to(MyClassNameProvider.class);
+    }
+  }
+
+  protected static class MyClassNameProvider implements DynamicOptions.ModulesClassNamesProvider {
+    @Override
+    public String getClassName() {
+      return "com.google.gerrit.acceptance.AbstractLifecycleListenersTest$MyOptions";
+    }
+
+    @Override
+    public Iterable<String> getModulesClassNames() {
+      return Collections.singleton(
+          "com.google.gerrit.acceptance.AbstractLifecycleListenersTest$MyOptions$MyOptionsModule");
+    }
+  }
+
+  public static class MyOptions implements DynamicOptions.DynamicBean {
+    @Option(name = "--opt")
+    public boolean opt;
+
+    public static class MyOptionsModule extends AbstractModule {
+      @Override
+      protected void configure() {
+        bind(LifecycleListener.class)
+            .annotatedWith(UniqueAnnotations.create())
+            .to(MyLifecycleListener.class);
+      }
+    }
+  }
+
+  protected static class MyLifecycleListener implements LifecycleListener {
+    protected final InvocationCheck invocationCheck;
+
+    @Inject
+    public MyLifecycleListener(InvocationCheck invocationCheck) {
+      this.invocationCheck = invocationCheck;
+    }
+
+    @Override
+    public void start() {
+      invocationCheck.setStartInvoked(true);
+    }
+
+    @Override
+    public void stop() {
+      invocationCheck.setStopInvoked(true);
+    }
+  }
+
+  @Singleton
+  public static class InvocationCheck {
+    private boolean isStartInvoked = false;
+    private boolean isStopInvoked = false;
+
+    public boolean isStartInvoked() {
+      return isStartInvoked;
+    }
+
+    public void setStartInvoked(boolean startInvoked) {
+      isStartInvoked = startInvoked;
+    }
+
+    public boolean isStopInvoked() {
+      return isStopInvoked;
+    }
+
+    public void setStopInvoked(boolean stopInvoked) {
+      isStopInvoked = stopInvoked;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
index a91bc49..91fbf9e 100644
--- a/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
@@ -30,7 +30,6 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.DynamicOptions.DynamicBean;
-import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.restapi.change.GetChange;
@@ -40,7 +39,6 @@
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
-import com.google.inject.Module;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.List;
@@ -86,21 +84,6 @@
     }
   }
 
-  protected static class NullAttributeModule extends AbstractModule {
-    @Override
-    public void configure() {
-      DynamicSet.bind(binder(), ChangeAttributeFactory.class).toInstance((cd, bp, p) -> null);
-    }
-  }
-
-  protected static class SimpleAttributeModule extends AbstractModule {
-    @Override
-    public void configure() {
-      DynamicSet.bind(binder(), ChangeAttributeFactory.class)
-          .toInstance((cd, bp, p) -> new MyInfo("change " + cd.getId()));
-    }
-  }
-
   protected static class PluginDefinedSimpleAttributeModule extends AbstractModule {
     @Override
     public void configure() {
@@ -170,21 +153,6 @@
     private String opt;
   }
 
-  protected static class OptionAttributeModule extends AbstractModule {
-    @Override
-    public void configure() {
-      DynamicSet.bind(binder(), ChangeAttributeFactory.class)
-          .toInstance(
-              (cd, bp, p) -> {
-                MyOptions opts = (MyOptions) bp.getDynamicBean(p);
-                return opts != null ? new MyInfo("opt " + opts.opt) : null;
-              });
-      bind(DynamicBean.class).annotatedWith(Exports.named(Query.class)).to(MyOptions.class);
-      bind(DynamicBean.class).annotatedWith(Exports.named(QueryChanges.class)).to(MyOptions.class);
-      bind(DynamicBean.class).annotatedWith(Exports.named(GetChange.class)).to(MyOptions.class);
-    }
-  }
-
   public static class BulkAttributeFactoryWithOption implements ChangePluginDefinedInfoFactory {
     protected MyOptions opts;
 
@@ -211,33 +179,6 @@
     }
   }
 
-  protected void getChangeWithNullAttribute(PluginInfoGetter getter) throws Exception {
-    Change.Id id = createChange().getChange().getId();
-    assertThat(getter.call(id)).isNull();
-
-    try (AutoCloseable ignored = installPlugin("my-plugin", NullAttributeModule.class)) {
-      assertThat(getter.call(id)).isNull();
-    }
-
-    assertThat(getter.call(id)).isNull();
-  }
-
-  protected void getChangeWithSimpleAttribute(PluginInfoGetter getter) throws Exception {
-    getChangeWithSimpleAttribute(getter, SimpleAttributeModule.class);
-  }
-
-  protected void getChangeWithSimpleAttribute(
-      PluginInfoGetter getter, Class<? extends Module> moduleClass) throws Exception {
-    Change.Id id = createChange().getChange().getId();
-    assertThat(getter.call(id)).isNull();
-
-    try (AutoCloseable ignored = installPlugin("my-plugin", moduleClass)) {
-      assertThat(getter.call(id)).containsExactly(new MyInfo("my-plugin", "change " + id));
-    }
-
-    assertThat(getter.call(id)).isNull();
-  }
-
   protected void getSingleChangeWithPluginDefinedBulkAttribute(BulkPluginInfoGetterWithId getter)
       throws Exception {
     Change.Id id = createChange().getChange().getId();
@@ -298,30 +239,6 @@
     assertThat(pluginInfos.get(changeWithInfo)).isNull();
   }
 
-  protected void getMultipleChangesWithPluginDefinedBulkAndChangeAttributes(
-      BulkPluginInfoGetter getter) throws Exception {
-    Change.Id id1 = createChange().getChange().getId();
-    Change.Id id2 = createChange().getChange().getId();
-
-    Map<Change.Id, List<PluginDefinedInfo>> pluginInfos = getter.call();
-    assertThat(pluginInfos.get(id1)).isNull();
-    assertThat(pluginInfos.get(id2)).isNull();
-
-    try (AutoCloseable ignored =
-            installPlugin("my-plugin-1", PluginDefinedSimpleAttributeModule.class);
-        AutoCloseable ignored1 = installPlugin("my-plugin-2", SimpleAttributeModule.class)) {
-      pluginInfos = getter.call();
-      assertThat(pluginInfos.get(id1)).contains(new MyInfo("my-plugin-1", "change " + id1));
-      assertThat(pluginInfos.get(id1)).contains(new MyInfo("my-plugin-2", "change " + id1));
-      assertThat(pluginInfos.get(id2)).contains(new MyInfo("my-plugin-1", "change " + id2));
-      assertThat(pluginInfos.get(id2)).contains(new MyInfo("my-plugin-2", "change " + id2));
-    }
-
-    pluginInfos = getter.call();
-    assertThat(pluginInfos.get(id1)).isNull();
-    assertThat(pluginInfos.get(id2)).isNull();
-  }
-
   protected void getMultipleChangesWithPluginDefinedBulkAttributeInSingleCall(
       BulkPluginInfoGetter getter) throws Exception {
     Change.Id id1 = createChange().getChange().getId();
@@ -345,22 +262,6 @@
     assertThat(pluginInfos.get(id2)).isNull();
   }
 
-  protected void getChangeWithOption(
-      PluginInfoGetter getterWithoutOptions, PluginInfoGetterWithOptions getterWithOptions)
-      throws Exception {
-    Change.Id id = createChange().getChange().getId();
-    assertThat(getterWithoutOptions.call(id)).isNull();
-
-    try (AutoCloseable ignored = installPlugin("my-plugin", OptionAttributeModule.class)) {
-      assertThat(getterWithoutOptions.call(id))
-          .containsExactly(new MyInfo("my-plugin", "opt null"));
-      assertThat(getterWithOptions.call(id, ImmutableListMultimap.of("my-plugin--opt", "foo")))
-          .containsExactly(new MyInfo("my-plugin", "opt foo"));
-    }
-
-    assertThat(getterWithoutOptions.call(id)).isNull();
-  }
-
   protected void getChangeWithPluginDefinedBulkAttributeOption(
       BulkPluginInfoGetterWithId getterWithoutOptions,
       BulkPluginInfoGetterWithIdAndOptions getterWithOptions)
@@ -387,7 +288,6 @@
 
     try (AutoCloseable ignored =
         installPlugin("my-plugin", PluginDefinedBulkExceptionModule.class)) {
-      PluginDefinedInfo errorInfo = new PluginDefinedInfo();
       List<PluginDefinedInfo> outputInfos = getter.call(id).get(id);
       assertThat(outputInfos).hasSize(1);
       assertThat(outputInfos.get(0).name).isEqualTo("my-plugin");
@@ -455,11 +355,6 @@
   }
 
   @FunctionalInterface
-  protected interface PluginInfoGetter {
-    List<PluginDefinedInfo> call(Change.Id id) throws Exception;
-  }
-
-  @FunctionalInterface
   protected interface BulkPluginInfoGetter {
     Map<Change.Id, List<PluginDefinedInfo>> call() throws Exception;
   }
@@ -474,10 +369,4 @@
     Map<Change.Id, List<PluginDefinedInfo>> call(
         Change.Id id, ImmutableListMultimap<String, String> pluginOptions) throws Exception;
   }
-
-  @FunctionalInterface
-  protected interface PluginInfoGetterWithOptions {
-    List<PluginDefinedInfo> call(Change.Id id, ImmutableListMultimap<String, String> pluginOptions)
-        throws Exception;
-  }
 }
diff --git a/java/com/google/gerrit/acceptance/AbstractPluginLogFileTest.java b/java/com/google/gerrit/acceptance/AbstractPluginLogFileTest.java
new file mode 100644
index 0000000..60def29
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/AbstractPluginLogFileTest.java
@@ -0,0 +1,112 @@
+// 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.extensions.systemstatus.ServerInformation;
+import com.google.gerrit.server.DynamicOptions;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.util.PluginLogFile;
+import com.google.gerrit.server.util.SystemLog;
+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.apache.log4j.AsyncAppender;
+import org.apache.log4j.Layout;
+import org.apache.log4j.PatternLayout;
+import org.eclipse.jgit.lib.Config;
+import org.kohsuke.args4j.Option;
+
+public class AbstractPluginLogFileTest extends AbstractDaemonTest {
+  protected static class Module extends AbstractModule {
+    @Override
+    public void configure() {
+      bind(com.google.gerrit.server.DynamicOptions.DynamicBean.class)
+          .annotatedWith(Exports.named(Query.class))
+          .to(MyClassNameProvider.class);
+    }
+  }
+
+  protected static class MyClassNameProvider implements DynamicOptions.ModulesClassNamesProvider {
+    @Override
+    public String getClassName() {
+      return "com.google.gerrit.acceptance.AbstractPluginLogFileTest$MyOptions";
+    }
+
+    @Override
+    public Iterable<String> getModulesClassNames() {
+      return Collections.singleton(
+          "com.google.gerrit.acceptance.AbstractPluginLogFileTest$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(MyPluginLogFile.class);
+      }
+    }
+  }
+
+  protected static class MyPluginLogFile extends PluginLogFile {
+    protected static final String logName = "test_log";
+
+    @Inject
+    public MyPluginLogFile(MySystemLog mySystemLog, ServerInformation serverInfo) {
+      super(mySystemLog, serverInfo, logName, new PatternLayout("[%d] [%t] %m%n"));
+    }
+  }
+
+  @Singleton
+  protected static class MySystemLog extends SystemLog {
+    protected InvocationCounter invocationCounter;
+
+    @Inject
+    public MySystemLog(SitePaths site, Config config, InvocationCounter invocationCounter) {
+      super(site, config);
+      this.invocationCounter = invocationCounter;
+    }
+
+    @Override
+    public AsyncAppender createAsyncAppender(
+        String name, Layout layout, boolean rotate, boolean forPlugin) {
+      invocationCounter.increment();
+      return super.createAsyncAppender(name, layout, rotate, forPlugin);
+    }
+  }
+
+  @Singleton
+  public static class InvocationCounter {
+    private int counter = 0;
+
+    public int getCounter() {
+      return counter;
+    }
+
+    public synchronized void increment() {
+      counter++;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/AccountCreator.java b/java/com/google/gerrit/acceptance/AccountCreator.java
index 3ab1cec..6897488 100644
--- a/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.db.GroupsUpdate;
@@ -110,7 +111,7 @@
           throw new NoSuchGroupException(n);
         }
         addGroupMember(group.get().getGroupUUID(), id);
-        if ("Service Users".equals(n)) {
+        if (ServiceUserClassifier.SERVICE_USERS.equals(n)) {
           tags.add("SERVICE_USER");
         }
       }
diff --git a/java/com/google/gerrit/acceptance/BUILD b/java/com/google/gerrit/acceptance/BUILD
index db0dc84..28f67b8 100644
--- a/java/com/google/gerrit/acceptance/BUILD
+++ b/java/com/google/gerrit/acceptance/BUILD
@@ -47,6 +47,7 @@
     "//lib/guice",
     "//lib/guice:guice-assistedinject",
     "//lib/guice:guice-servlet",
+    "//lib/log:log4j",
     "//lib/mail",
     "//lib/mina:sshd",
     "//lib:guava",
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index a5d8d19..5d01dcb 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.extensions.events.GroupIndexedListener;
 import com.google.gerrit.extensions.events.ProjectIndexedListener;
 import com.google.gerrit.extensions.events.RevisionCreatedListener;
+import com.google.gerrit.extensions.events.TopicEditedListener;
 import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -42,6 +43,7 @@
 import com.google.gerrit.server.git.validators.OnSubmitValidationListener;
 import com.google.gerrit.server.git.validators.RefOperationValidationListener;
 import com.google.gerrit.server.logging.PerformanceLogger;
+import com.google.gerrit.server.restapi.change.OnPostReview;
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.gerrit.server.validators.AccountActivationValidationListener;
 import com.google.gerrit.server.validators.ProjectCreationValidationListener;
@@ -58,6 +60,7 @@
   private final DynamicSet<GroupIndexedListener> groupIndexedListeners;
   private final DynamicSet<ProjectIndexedListener> projectIndexedListeners;
   private final DynamicSet<CommitValidationListener> commitValidationListeners;
+  private final DynamicSet<TopicEditedListener> topicEditedListeners;
   private final DynamicSet<ExceptionHook> exceptionHooks;
   private final DynamicSet<PerformanceLogger> performanceLoggers;
   private final DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners;
@@ -81,6 +84,7 @@
   private final DynamicMap<CapabilityDefinition> capabilityDefinitions;
   private final DynamicMap<PluginProjectPermissionDefinition> pluginProjectPermissionDefinitions;
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
+  private final DynamicSet<OnPostReview> onPostReviews;
 
   @Inject
   ExtensionRegistry(
@@ -89,6 +93,7 @@
       DynamicSet<GroupIndexedListener> groupIndexedListeners,
       DynamicSet<ProjectIndexedListener> projectIndexedListeners,
       DynamicSet<CommitValidationListener> commitValidationListeners,
+      DynamicSet<TopicEditedListener> topicEditedListeners,
       DynamicSet<ExceptionHook> exceptionHooks,
       DynamicSet<PerformanceLogger> performanceLoggers,
       DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners,
@@ -110,12 +115,14 @@
       DynamicSet<WorkInProgressStateChangedListener> workInProgressStateChangedListeners,
       DynamicMap<CapabilityDefinition> capabilityDefinitions,
       DynamicMap<PluginProjectPermissionDefinition> pluginProjectPermissionDefinitions,
-      DynamicMap<ProjectConfigEntry> pluginConfigEntries) {
+      DynamicMap<ProjectConfigEntry> pluginConfigEntries,
+      DynamicSet<OnPostReview> onPostReviews) {
     this.accountIndexedListeners = accountIndexedListeners;
     this.changeIndexedListeners = changeIndexedListeners;
     this.groupIndexedListeners = groupIndexedListeners;
     this.projectIndexedListeners = projectIndexedListeners;
     this.commitValidationListeners = commitValidationListeners;
+    this.topicEditedListeners = topicEditedListeners;
     this.exceptionHooks = exceptionHooks;
     this.performanceLoggers = performanceLoggers;
     this.projectCreationValidationListeners = projectCreationValidationListeners;
@@ -138,6 +145,7 @@
     this.capabilityDefinitions = capabilityDefinitions;
     this.pluginProjectPermissionDefinitions = pluginProjectPermissionDefinitions;
     this.pluginConfigEntries = pluginConfigEntries;
+    this.onPostReviews = onPostReviews;
   }
 
   public Registration newRegistration() {
@@ -168,6 +176,10 @@
       return add(commitValidationListeners, commitValidationListener);
     }
 
+    public Registration add(TopicEditedListener topicEditedListener) {
+      return add(topicEditedListeners, topicEditedListener);
+    }
+
     public Registration add(ExceptionHook exceptionHook) {
       return add(exceptionHooks, exceptionHook);
     }
@@ -262,6 +274,10 @@
       return add(pluginConfigEntries, pluginConfigEntry, exportName);
     }
 
+    public Registration add(OnPostReview onPostReview) {
+      return add(onPostReviews, onPostReview);
+    }
+
     private <T> Registration add(DynamicSet<T> dynamicSet, T extension) {
       return add(dynamicSet, extension, "gerrit");
     }
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 3f8a5a8..f2cc9d1 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -50,6 +50,7 @@
 import com.google.gerrit.server.config.GerritRuntime;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePath;
+import com.google.gerrit.server.experiments.ConfigExperimentFeatures;
 import com.google.gerrit.server.git.receive.AsyncReceiveCommits;
 import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
 import com.google.gerrit.server.ssh.NoSshModule;
@@ -437,7 +438,8 @@
               protected void configure() {
                 bind(GerritRuntime.class).toInstance(GerritRuntime.DAEMON);
               }
-            }));
+            },
+            new ConfigExperimentFeatures.Module()));
     daemon.addAdditionalSysModuleForTesting(
         new ReindexProjectsAtStartup.Module(), new ReindexGroupsAtStartup.Module());
     daemon.start();
@@ -590,7 +592,7 @@
     httpAddress = new InetSocketAddress(uri.getHost(), uri.getPort());
   }
 
-  String getUrl() {
+  public String getUrl() {
     return url;
   }
 
diff --git a/java/com/google/gerrit/acceptance/PushOneCommit.java b/java/com/google/gerrit/acceptance/PushOneCommit.java
index afd451a..4215255 100644
--- a/java/com/google/gerrit/acceptance/PushOneCommit.java
+++ b/java/com/google/gerrit/acceptance/PushOneCommit.java
@@ -43,8 +43,12 @@
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.api.TagCommand;
+import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
+import org.eclipse.jgit.dircache.DirCacheEntry;
 import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.FileMode;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevBlob;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RemoteRefUpdate;
@@ -277,6 +281,19 @@
     return this;
   }
 
+  public PushOneCommit addSymlink(String path, String target) throws Exception {
+    RevBlob blobId = testRepo.blob(target);
+    commitBuilder.edit(
+        new PathEdit(path) {
+          @Override
+          public void apply(DirCacheEntry ent) {
+            ent.setFileMode(FileMode.SYMLINK);
+            ent.setObjectId(blobId);
+          }
+        });
+    return this;
+  }
+
   public Result to(String ref) throws Exception {
     for (Map.Entry<String, String> e : files.entrySet()) {
       commitBuilder.add(e.getKey(), e.getValue());
diff --git a/java/com/google/gerrit/server/config/AuthModule.java b/java/com/google/gerrit/auth/AuthModule.java
similarity index 84%
rename from java/com/google/gerrit/server/config/AuthModule.java
rename to java/com/google/gerrit/auth/AuthModule.java
index 5b0f73d..b17cbf0 100644
--- a/java/com/google/gerrit/server/config/AuthModule.java
+++ b/java/com/google/gerrit/auth/AuthModule.java
@@ -12,29 +12,31 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.config;
+package com.google.gerrit.auth;
 
+import com.google.gerrit.auth.ldap.LdapModule;
+import com.google.gerrit.auth.oauth.OAuthRealm;
+import com.google.gerrit.auth.oauth.OAuthTokenCache;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.account.DefaultRealm;
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.auth.AuthBackend;
 import com.google.gerrit.server.auth.InternalAuthBackend;
-import com.google.gerrit.server.auth.ldap.LdapModule;
-import com.google.gerrit.server.auth.oauth.OAuthRealm;
+import com.google.gerrit.server.config.AuthConfig;
 import com.google.inject.AbstractModule;
-import com.google.inject.Inject;
 
 public class AuthModule extends AbstractModule {
   private final AuthType loginType;
 
-  @Inject
-  AuthModule(AuthConfig authConfig) {
+  public AuthModule(AuthConfig authConfig) {
     loginType = authConfig.getAuthType();
   }
 
   @Override
   protected void configure() {
+    install(OAuthTokenCache.module());
+
     switch (loginType) {
       case HTTP_LDAP:
       case LDAP:
diff --git a/java/com/google/gerrit/auth/BUILD b/java/com/google/gerrit/auth/BUILD
new file mode 100644
index 0000000..609ec8a
--- /dev/null
+++ b/java/com/google/gerrit/auth/BUILD
@@ -0,0 +1,39 @@
+load("@rules_java//java:defs.bzl", "java_library")
+load("//tools/bzl:javadoc.bzl", "java_doc")
+
+# Giant kitchen-sink target.
+#
+# The only reason this hasn't been split up further is because we have too many
+# tangled dependencies (and Guice unfortunately makes it quite easy to get into
+# this state). Which means if you see an opportunity to split something off, you
+# should seize it.
+java_library(
+    name = "auth",
+    srcs = glob(
+        ["**/*.java"],
+    ),
+    resource_strip_prefix = "resources",
+    resources = ["//resources/com/google/gerrit/server"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/proto",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/cache/serialize",
+        "//java/com/google/gerrit/server/logging",
+        "//java/com/google/gerrit/util/ssl",
+        "//lib:guava",
+        "//lib:jgit",
+        "//lib:protobuf",
+        "//lib:servlet-api",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
+        "//lib/errorprone:annotations",
+        "//lib/flogger:api",
+        "//lib/guice",
+        "//proto:cache_java_proto",
+    ],
+)
diff --git a/java/com/google/gerrit/server/auth/ldap/FakeLdapGroupBackend.java b/java/com/google/gerrit/auth/ldap/FakeLdapGroupBackend.java
similarity index 90%
rename from java/com/google/gerrit/server/auth/ldap/FakeLdapGroupBackend.java
rename to java/com/google/gerrit/auth/ldap/FakeLdapGroupBackend.java
index 63cd426..8fb4d35 100644
--- a/java/com/google/gerrit/server/auth/ldap/FakeLdapGroupBackend.java
+++ b/java/com/google/gerrit/auth/ldap/FakeLdapGroupBackend.java
@@ -12,15 +12,15 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.ldap;
+package com.google.gerrit.auth.ldap;
 
-import static com.google.gerrit.server.auth.ldap.Helper.LDAP_UUID;
+import static com.google.gerrit.auth.ldap.Helper.LDAP_UUID;
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
-import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.ListGroupMembership;
@@ -76,7 +76,7 @@
   }
 
   @Override
-  public GroupMembership membershipsOf(IdentifiedUser user) {
+  public GroupMembership membershipsOf(CurrentUser user) {
     return new ListGroupMembership(Collections.emptyList());
   }
 
diff --git a/java/com/google/gerrit/server/auth/ldap/Helper.java b/java/com/google/gerrit/auth/ldap/Helper.java
similarity index 99%
rename from java/com/google/gerrit/server/auth/ldap/Helper.java
rename to java/com/google/gerrit/auth/ldap/Helper.java
index 5c6b391..bf2a8c2 100644
--- a/java/com/google/gerrit/server/auth/ldap/Helper.java
+++ b/java/com/google/gerrit/auth/ldap/Helper.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.ldap;
+package com.google.gerrit.auth.ldap;
 
 import com.google.common.base.Throwables;
 import com.google.common.cache.Cache;
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapAuthBackend.java b/java/com/google/gerrit/auth/ldap/LdapAuthBackend.java
similarity index 98%
rename from java/com/google/gerrit/server/auth/ldap/LdapAuthBackend.java
rename to java/com/google/gerrit/auth/ldap/LdapAuthBackend.java
index f31954e..017655f 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapAuthBackend.java
+++ b/java/com/google/gerrit/auth/ldap/LdapAuthBackend.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.ldap;
+package com.google.gerrit.auth.ldap;
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.client.AuthType;
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java b/java/com/google/gerrit/auth/ldap/LdapGroupBackend.java
similarity index 90%
rename from java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
rename to java/com/google/gerrit/auth/ldap/LdapGroupBackend.java
index 180612c..f82523e 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
+++ b/java/com/google/gerrit/auth/ldap/LdapGroupBackend.java
@@ -12,28 +12,27 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.ldap;
+package com.google.gerrit.auth.ldap;
 
+import static com.google.gerrit.auth.ldap.Helper.LDAP_UUID;
+import static com.google.gerrit.auth.ldap.LdapModule.GROUP_CACHE;
+import static com.google.gerrit.auth.ldap.LdapModule.GROUP_EXIST_CACHE;
 import static com.google.gerrit.server.account.GroupBackends.GROUP_REF_NAME_COMPARATOR;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
-import static com.google.gerrit.server.auth.ldap.Helper.LDAP_UUID;
-import static com.google.gerrit.server.auth.ldap.LdapModule.GROUP_CACHE;
-import static com.google.gerrit.server.auth.ldap.LdapModule.GROUP_EXIST_CACHE;
 
 import com.google.common.cache.LoadingCache;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.auth.ldap.Helper.LdapSchema;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.ParameterizedString;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.auth.ldap.Helper.LdapSchema;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
@@ -45,6 +44,7 @@
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
 import javax.naming.InvalidNameException;
@@ -178,21 +178,13 @@
   }
 
   @Override
-  public GroupMembership membershipsOf(IdentifiedUser user) {
-    String id = findId(user.state().externalIds());
-    if (id == null) {
+  public GroupMembership membershipsOf(CurrentUser user) {
+    Optional<ExternalId.Key> id =
+        user.getExternalIdKeys().stream().filter(e -> e.isScheme(SCHEME_GERRIT)).findAny();
+    if (!id.isPresent()) {
       return GroupMembership.EMPTY;
     }
-    return new LdapGroupMembership(membershipCache, projectCache, id, gerritConfig);
-  }
-
-  private static String findId(Collection<ExternalId> extIds) {
-    for (ExternalId extId : extIds) {
-      if (extId.isScheme(SCHEME_GERRIT)) {
-        return extId.key().id();
-      }
-    }
-    return null;
+    return new LdapGroupMembership(membershipCache, projectCache, id.get().id(), gerritConfig);
   }
 
   private Set<GroupReference> suggestLdap(String name) {
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapGroupMembership.java b/java/com/google/gerrit/auth/ldap/LdapGroupMembership.java
similarity index 98%
rename from java/com/google/gerrit/server/auth/ldap/LdapGroupMembership.java
rename to java/com/google/gerrit/auth/ldap/LdapGroupMembership.java
index a6aa2f6..0b8b6e2 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapGroupMembership.java
+++ b/java/com/google/gerrit/auth/ldap/LdapGroupMembership.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.ldap;
+package com.google.gerrit.auth.ldap;
 
 import com.google.common.cache.LoadingCache;
 import com.google.gerrit.entities.AccountGroup;
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapModule.java b/java/com/google/gerrit/auth/ldap/LdapModule.java
similarity index 97%
rename from java/com/google/gerrit/server/auth/ldap/LdapModule.java
rename to java/com/google/gerrit/auth/ldap/LdapModule.java
index 092b5ac..a5ee904 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapModule.java
+++ b/java/com/google/gerrit/auth/ldap/LdapModule.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.ldap;
+package com.google.gerrit.auth.ldap;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapQuery.java b/java/com/google/gerrit/auth/ldap/LdapQuery.java
similarity index 98%
rename from java/com/google/gerrit/server/auth/ldap/LdapQuery.java
rename to java/com/google/gerrit/auth/ldap/LdapQuery.java
index 3d25e86..2586fd4 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapQuery.java
+++ b/java/com/google/gerrit/auth/ldap/LdapQuery.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.ldap;
+package com.google.gerrit.auth.ldap;
 
 import com.google.gerrit.common.data.ParameterizedString;
 import java.util.ArrayList;
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapRealm.java b/java/com/google/gerrit/auth/ldap/LdapRealm.java
similarity index 99%
rename from java/com/google/gerrit/server/auth/ldap/LdapRealm.java
rename to java/com/google/gerrit/auth/ldap/LdapRealm.java
index b5972e2..9305914 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
+++ b/java/com/google/gerrit/auth/ldap/LdapRealm.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.ldap;
+package com.google.gerrit.auth.ldap;
 
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
 
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapType.java b/java/com/google/gerrit/auth/ldap/LdapType.java
similarity index 98%
rename from java/com/google/gerrit/server/auth/ldap/LdapType.java
rename to java/com/google/gerrit/auth/ldap/LdapType.java
index fe1f1ff..c486335 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapType.java
+++ b/java/com/google/gerrit/auth/ldap/LdapType.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.ldap;
+package com.google.gerrit.auth.ldap;
 
 import javax.naming.NamingException;
 import javax.naming.directory.Attribute;
diff --git a/java/com/google/gerrit/server/auth/ldap/SearchScope.java b/java/com/google/gerrit/auth/ldap/SearchScope.java
similarity index 96%
rename from java/com/google/gerrit/server/auth/ldap/SearchScope.java
rename to java/com/google/gerrit/auth/ldap/SearchScope.java
index 0038608..75edd8d 100644
--- a/java/com/google/gerrit/server/auth/ldap/SearchScope.java
+++ b/java/com/google/gerrit/auth/ldap/SearchScope.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.ldap;
+package com.google.gerrit.auth.ldap;
 
 import javax.naming.directory.SearchControls;
 
diff --git a/java/com/google/gerrit/server/auth/oauth/OAuthRealm.java b/java/com/google/gerrit/auth/oauth/OAuthRealm.java
similarity index 98%
rename from java/com/google/gerrit/server/auth/oauth/OAuthRealm.java
rename to java/com/google/gerrit/auth/oauth/OAuthRealm.java
index 944bd44..c329cc0 100644
--- a/java/com/google/gerrit/server/auth/oauth/OAuthRealm.java
+++ b/java/com/google/gerrit/auth/oauth/OAuthRealm.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.oauth;
+package com.google.gerrit.auth.oauth;
 
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_EXTERNAL;
 
diff --git a/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java b/java/com/google/gerrit/auth/oauth/OAuthTokenCache.java
similarity index 98%
rename from java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
rename to java/com/google/gerrit/auth/oauth/OAuthTokenCache.java
index 03ecd91..b0c1f51 100644
--- a/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
+++ b/java/com/google/gerrit/auth/oauth/OAuthTokenCache.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.oauth;
+package com.google.gerrit.auth.oauth;
 
 import static java.util.Objects.requireNonNull;
 
diff --git a/java/com/google/gerrit/common/data/testing/BUILD b/java/com/google/gerrit/common/data/testing/BUILD
index b9ec30b..d39d05c 100644
--- a/java/com/google/gerrit/common/data/testing/BUILD
+++ b/java/com/google/gerrit/common/data/testing/BUILD
@@ -6,7 +6,6 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
-        "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//lib/truth",
     ],
diff --git a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
index e56f470..44a377a 100644
--- a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
+++ b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
@@ -84,6 +84,9 @@
   protected static final String BULK = "_bulk";
   protected static final String MAPPINGS = "mappings";
   protected static final String ORDER = "order";
+  protected static final String DESC_SORT_ORDER = "desc";
+  protected static final String ASC_SORT_ORDER = "asc";
+  protected static final String UNMAPPED_TYPE = "unmapped_type";
   protected static final String SEARCH = "_search";
   protected static final String SETTINGS = "settings";
 
@@ -288,7 +291,7 @@
 
   protected JsonArray getSortArray(String idFieldName) {
     JsonObject properties = new JsonObject();
-    properties.addProperty(ORDER, "asc");
+    properties.addProperty(ORDER, ASC_SORT_ORDER);
 
     JsonArray sortArray = new JsonArray();
     addNamedElement(idFieldName, properties, sortArray);
diff --git a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
index 625a598..162654d 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.Predicate;
@@ -57,6 +58,9 @@
 import com.google.gson.JsonObject;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.format.DateTimeFormatter;
 import java.util.Collections;
 import java.util.Optional;
 import java.util.Set;
@@ -133,14 +137,24 @@
 
   private JsonArray getSortArray() {
     JsonObject properties = new JsonObject();
-    properties.addProperty(ORDER, "desc");
+    properties.addProperty(ORDER, DESC_SORT_ORDER);
 
     JsonArray sortArray = new JsonArray();
     addNamedElement(ChangeField.UPDATED.getName(), properties, sortArray);
+    addNamedElement(ChangeField.MERGED_ON.getName(), getMergedOnSortOptions(), sortArray);
     addNamedElement(idField.getName(), properties, sortArray);
     return sortArray;
   }
 
+  private JsonObject getMergedOnSortOptions() {
+    JsonObject sortOptions = new JsonObject();
+    sortOptions.addProperty(ORDER, DESC_SORT_ORDER);
+    // Ignore the sort field if it does not exist in index. Otherwise the search would fail on open
+    // changes, because the corresponding documents do not have mergedOn field.
+    sortOptions.addProperty(UNMAPPED_TYPE, ElasticMapping.TIMESTAMP_FIELD_TYPE);
+    return sortOptions;
+  }
+
   @Override
   protected String getDeleteActions(Change.Id c) {
     return getDeleteRequest(c);
@@ -341,7 +355,7 @@
 
     // Ref-state.
     if (fields.contains(ChangeField.REF_STATE.getName())) {
-      cd.setRefStates(getByteArray(source, ChangeField.REF_STATE.getName()));
+      cd.setRefStates(RefState.parseStates(getByteArray(source, ChangeField.REF_STATE.getName())));
     }
 
     // Ref-state-pattern.
@@ -361,6 +375,10 @@
           cd);
     }
 
+    if (fields.contains(ChangeField.MERGED_ON.getName())) {
+      decodeMergedOn(source, cd);
+    }
+
     return cd;
   }
 
@@ -396,4 +414,18 @@
     }
     out.setUnresolvedCommentCount(count.getAsInt());
   }
+
+  private void decodeMergedOn(JsonObject doc, ChangeData out) {
+    JsonElement mergedOnField = doc.get(ChangeField.MERGED_ON.getName());
+
+    Timestamp mergedOn = null;
+    if (mergedOnField != null) {
+      // Parse from ElasticMapping.TIMESTAMP_FIELD_FORMAT.
+      // We currently use built-in ISO-based dateOptionalTime.
+      // https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-date-format.html#built-in-date-formats
+      DateTimeFormatter isoFormatter = DateTimeFormatter.ISO_INSTANT;
+      mergedOn = Timestamp.from(Instant.from(isoFormatter.parse(mergedOnField.getAsString())));
+    }
+    out.setMergedOn(mergedOn);
+  }
 }
diff --git a/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java b/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
index 06b128c..c4435297 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
@@ -26,8 +26,10 @@
 import java.net.URISyntaxException;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.TimeUnit;
 import org.apache.http.HttpHost;
 import org.eclipse.jgit.lib.Config;
+import org.elasticsearch.client.RestClientBuilder;
 
 @Singleton
 class ElasticConfiguration {
@@ -41,12 +43,16 @@
   static final String KEY_NUMBER_OF_SHARDS = "numberOfShards";
   static final String KEY_NUMBER_OF_REPLICAS = "numberOfReplicas";
   static final String KEY_MAX_RESULT_WINDOW = "maxResultWindow";
+  static final String KEY_CONNECT_TIMEOUT = "connectTimeout";
+  static final String KEY_SOCKET_TIMEOUT = "socketTimeout";
 
   static final String DEFAULT_PORT = "9200";
   static final String DEFAULT_USERNAME = "elastic";
   static final int DEFAULT_NUMBER_OF_SHARDS = 1;
   static final int DEFAULT_NUMBER_OF_REPLICAS = 1;
   static final int DEFAULT_MAX_RESULT_WINDOW = 10000;
+  static final int DEFAULT_CONNECT_TIMEOUT = RestClientBuilder.DEFAULT_CONNECT_TIMEOUT_MILLIS;
+  static final int DEFAULT_SOCKET_TIMEOUT = RestClientBuilder.DEFAULT_SOCKET_TIMEOUT_MILLIS;
 
   private final Config cfg;
   private final List<HttpHost> hosts;
@@ -56,6 +62,8 @@
   final int numberOfShards;
   final int numberOfReplicas;
   final int maxResultWindow;
+  final int connectTimeout;
+  final int socketTimeout;
   final String prefix;
 
   @Inject
@@ -74,6 +82,22 @@
         cfg.getInt(SECTION_ELASTICSEARCH, null, KEY_NUMBER_OF_REPLICAS, DEFAULT_NUMBER_OF_REPLICAS);
     this.maxResultWindow =
         cfg.getInt(SECTION_ELASTICSEARCH, null, KEY_MAX_RESULT_WINDOW, DEFAULT_MAX_RESULT_WINDOW);
+    this.connectTimeout =
+        (int)
+            cfg.getTimeUnit(
+                SECTION_ELASTICSEARCH,
+                null,
+                KEY_CONNECT_TIMEOUT,
+                DEFAULT_CONNECT_TIMEOUT,
+                TimeUnit.MILLISECONDS);
+    this.socketTimeout =
+        (int)
+            cfg.getTimeUnit(
+                SECTION_ELASTICSEARCH,
+                null,
+                KEY_SOCKET_TIMEOUT,
+                DEFAULT_SOCKET_TIMEOUT,
+                TimeUnit.MILLISECONDS);
     this.hosts = new ArrayList<>();
     for (String server : cfg.getStringList(SECTION_ELASTICSEARCH, null, KEY_SERVER)) {
       try {
diff --git a/java/com/google/gerrit/elasticsearch/ElasticMapping.java b/java/com/google/gerrit/elasticsearch/ElasticMapping.java
index f8c4168..edd05c9 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticMapping.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticMapping.java
@@ -21,6 +21,10 @@
 import java.util.Map;
 
 class ElasticMapping {
+
+  protected static final String TIMESTAMP_FIELD_TYPE = "date";
+  protected static final String TIMESTAMP_FIELD_FORMAT = "dateOptionalTime";
+
   static MappingProperties createMapping(Schema<?> schema, ElasticQueryAdapter adapter) {
     ElasticMapping.Builder mapping = new ElasticMapping.Builder(adapter);
     for (FieldDef<?, ?> field : schema.getFields().values()) {
@@ -71,9 +75,9 @@
     }
 
     Builder addTimestamp(String name) {
-      FieldProperties properties = new FieldProperties("date");
-      properties.type = "date";
-      properties.format = "dateOptionalTime";
+      FieldProperties properties = new FieldProperties(TIMESTAMP_FIELD_TYPE);
+      properties.type = TIMESTAMP_FIELD_TYPE;
+      properties.format = TIMESTAMP_FIELD_FORMAT;
       fields.put(name, properties);
       return this;
     }
diff --git a/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java b/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
index d05e91c..40ac603 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
@@ -29,7 +29,6 @@
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.RegexPredicate;
 import com.google.gerrit.index.query.TimestampRangePredicate;
-import com.google.gerrit.server.query.change.AfterPredicate;
 import java.time.Instant;
 
 public class ElasticQueryBuilder {
@@ -130,7 +129,9 @@
   private <T> QueryBuilder timestampQuery(IndexPredicate<T> p) throws QueryParseException {
     if (p instanceof TimestampRangePredicate) {
       TimestampRangePredicate<T> r = (TimestampRangePredicate<T>) p;
-      if (p instanceof AfterPredicate) {
+      if (r.getMaxTimestamp().getTime() == Long.MAX_VALUE) {
+        // The time range only has the start value, search from the start to the max supported value
+        // Long.MAX_VALUE
         return QueryBuilders.rangeQuery(r.getField().getName())
             .gte(Instant.ofEpochMilli(r.getMinTimestamp().getTime()));
       }
diff --git a/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java b/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
index f635b23..b41f365 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
@@ -27,6 +27,7 @@
 import org.apache.http.auth.AuthScope;
 import org.apache.http.auth.UsernamePasswordCredentials;
 import org.apache.http.client.CredentialsProvider;
+import org.apache.http.client.config.RequestConfig;
 import org.apache.http.impl.client.BasicCredentialsProvider;
 import org.apache.http.impl.nio.client.HttpAsyncClientBuilder;
 import org.elasticsearch.client.Request;
@@ -128,10 +129,19 @@
 
   private RestClient build() {
     RestClientBuilder builder = RestClient.builder(cfg.getHosts());
+    setConfiguredTimeouts(builder);
     setConfiguredCredentialsIfAny(builder);
     return builder.build();
   }
 
+  private void setConfiguredTimeouts(RestClientBuilder builder) {
+    builder.setRequestConfigCallback(
+        (RequestConfig.Builder requestConfigBuilder) ->
+            requestConfigBuilder
+                .setConnectTimeout(cfg.connectTimeout)
+                .setSocketTimeout(cfg.socketTimeout));
+  }
+
   private void setConfiguredCredentialsIfAny(RestClientBuilder builder) {
     String username = cfg.username;
     String password = cfg.password;
diff --git a/java/com/google/gerrit/entities/BUILD b/java/com/google/gerrit/entities/BUILD
index 66d1869..c0f5de6 100644
--- a/java/com/google/gerrit/entities/BUILD
+++ b/java/com/google/gerrit/entities/BUILD
@@ -10,11 +10,13 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/extensions:api",
+        "//lib:gson",
         "//lib:guava",
         "//lib:jgit",
         "//lib:protobuf",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
+        "//lib/auto:auto-value-gson",
         "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//proto:cache_java_proto",
diff --git a/java/com/google/gerrit/entities/CachedProjectConfig.java b/java/com/google/gerrit/entities/CachedProjectConfig.java
index 0b755b7..2a94bc8 100644
--- a/java/com/google/gerrit/entities/CachedProjectConfig.java
+++ b/java/com/google/gerrit/entities/CachedProjectConfig.java
@@ -14,19 +14,17 @@
 
 package com.google.gerrit.entities;
 
-import static com.google.common.base.Preconditions.checkState;
-
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import org.eclipse.jgit.annotations.Nullable;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 
 /**
@@ -37,6 +35,8 @@
  */
 @AutoValue
 public abstract class CachedProjectConfig {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public abstract Project getProject();
 
   public abstract ImmutableMap<AccountGroup.UUID, GroupReference> getGroups();
@@ -126,34 +126,10 @@
 
   public abstract ImmutableMap<String, String> getPluginConfigs();
 
-  /**
-   * Returns the {@link Config} that got parsed from the specified {@code fileName} on {@code
-   * refs/meta/config}. The returned instance is a defensive copy of the cached value.
-   *
-   * @param fileName the name of the file. Must end in {@code .config}.
-   * @return an {@link Optional} of the {@link Config}. {@link Optional#empty()} if the file was not
-   *     found or could not be parsed. {@link com.google.gerrit.server.project.ProjectConfig} will
-   *     surface validation errors in case of a parsing issue.
-   */
-  public Optional<Config> getProjectLevelConfig(String fileName) {
-    checkState(fileName.endsWith(".config"), "file name must end in .config");
-    if (getProjectLevelConfigs().containsKey(fileName)) {
-      Config config = new Config();
-      try {
-        config.fromText(getProjectLevelConfigs().get(fileName));
-      } catch (ConfigInvalidException e) {
-        // This is OK to propagate as IllegalStateException because it's a programmer error.
-        // The config was converted to a String using Config#toText. So #fromText must not
-        // throw a ConfigInvalidException
-        throw new IllegalStateException("invalid config for " + fileName, e);
-      }
-      return Optional.of(config);
-    }
-    return Optional.empty();
-  }
-
   public abstract ImmutableMap<String, String> getProjectLevelConfigs();
 
+  public abstract ImmutableMap<String, ImmutableConfig> getParsedProjectLevelConfigs();
+
   public static Builder builder() {
     return new AutoValue_CachedProjectConfig.Builder();
   }
@@ -235,8 +211,15 @@
 
     abstract ImmutableMap.Builder<String, String> projectLevelConfigsBuilder();
 
+    abstract ImmutableMap.Builder<String, ImmutableConfig> parsedProjectLevelConfigsBuilder();
+
     public Builder addProjectLevelConfig(String configFileName, String config) {
       projectLevelConfigsBuilder().put(configFileName, config);
+      try {
+        parsedProjectLevelConfigsBuilder().put(configFileName, ImmutableConfig.parse(config));
+      } catch (ConfigInvalidException e) {
+        logger.atInfo().withCause(e).log("Config for " + configFileName + " not parsable");
+      }
       return this;
     }
 
diff --git a/java/com/google/gerrit/entities/Change.java b/java/com/google/gerrit/entities/Change.java
index 845a9bb..ca13db9 100644
--- a/java/com/google/gerrit/entities/Change.java
+++ b/java/com/google/gerrit/entities/Change.java
@@ -21,6 +21,9 @@
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gson.Gson;
+import com.google.gson.TypeAdapter;
+import com.google.gson.annotations.SerializedName;
 import java.sql.Timestamp;
 import java.util.Arrays;
 import java.util.Optional;
@@ -100,6 +103,7 @@
     return new AutoValue_Change_Id(id);
   }
 
+  /** The numeric change ID */
   @AutoValue
   public abstract static class Id {
     /**
@@ -283,6 +287,7 @@
       return Change.key(KeyUtil.decode(str));
     }
 
+    @SerializedName("id")
     abstract String key();
 
     public String get() {
@@ -307,6 +312,10 @@
     public final String toString() {
       return get();
     }
+
+    public static TypeAdapter<Key> typeAdapter(Gson gson) {
+      return new AutoValue_Change_Key.GsonTypeAdapter(gson);
+    }
   }
 
   /** Minimum database status constant for an open change. */
@@ -447,20 +456,14 @@
    */
   protected Timestamp lastUpdatedOn;
 
-  // DELETED: id = 6 (sortkey)
-
   protected Account.Id owner;
 
   /** The branch (and project) this change merges into. */
   protected BranchNameKey dest;
 
-  // DELETED: id = 9 (open)
-
   /** Current state code; see {@link Status}. */
   protected char status;
 
-  // DELETED: id = 11 (nbrPatchSets)
-
   /** The current patch set. */
   protected int currentPatchSetId;
 
@@ -470,9 +473,6 @@
   /** Topic name assigned by the user, if any. */
   @Nullable protected String topic;
 
-  // DELETED: id = 15 (lastSha1MergeTested)
-  // DELETED: id = 16 (mergeable)
-
   /**
    * First line of first patch set's commit message.
    *
@@ -544,12 +544,12 @@
     cherryPickOf = other.cherryPickOf;
   }
 
-  /** Legacy 32 bit integer identity for a change. */
+  /** 32 bit integer identity for a change. */
   public Change.Id getId() {
     return changeId;
   }
 
-  /** Legacy 32 bit integer identity for a change. */
+  /** 32 bit integer identity for a change. */
   public int getChangeId() {
     return changeId.get();
   }
diff --git a/java/com/google/gerrit/entities/CommentContext.java b/java/com/google/gerrit/entities/CommentContext.java
new file mode 100644
index 0000000..c8c8a76
--- /dev/null
+++ b/java/com/google/gerrit/entities/CommentContext.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableMap;
+
+/** An entity class representing all context lines of a comment. */
+@AutoValue
+public abstract class CommentContext {
+  private static final CommentContext EMPTY = new AutoValue_CommentContext(ImmutableMap.of());
+
+  public static CommentContext create(ImmutableMap<Integer, String> lines) {
+    return new AutoValue_CommentContext(lines);
+  }
+
+  /** Map of {line number, line text} of the context lines of a comment */
+  public abstract ImmutableMap<Integer, String> lines();
+
+  public static CommentContext empty() {
+    return EMPTY;
+  }
+}
diff --git a/java/com/google/gerrit/entities/CoreDownloadSchemes.java b/java/com/google/gerrit/entities/CoreDownloadSchemes.java
index 37c10f1..9bcd365 100644
--- a/java/com/google/gerrit/entities/CoreDownloadSchemes.java
+++ b/java/com/google/gerrit/entities/CoreDownloadSchemes.java
@@ -21,6 +21,7 @@
   public static final String HTTP = "http";
   public static final String SSH = "ssh";
   public static final String REPO_DOWNLOAD = "repo";
+  public static final String REPO = "repo";
 
   private CoreDownloadSchemes() {}
 }
diff --git a/java/com/google/gerrit/entities/EntitiesAdapterFactory.java b/java/com/google/gerrit/entities/EntitiesAdapterFactory.java
new file mode 100644
index 0000000..e6a06fd
--- /dev/null
+++ b/java/com/google/gerrit/entities/EntitiesAdapterFactory.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import com.google.gson.TypeAdapterFactory;
+import com.ryanharter.auto.value.gson.GsonTypeAdapterFactory;
+
+@GsonTypeAdapterFactory
+public abstract class EntitiesAdapterFactory implements TypeAdapterFactory {
+  public static TypeAdapterFactory create() {
+    return new AutoValueGson_EntitiesAdapterFactory();
+  }
+}
diff --git a/java/com/google/gerrit/entities/ImmutableConfig.java b/java/com/google/gerrit/entities/ImmutableConfig.java
new file mode 100644
index 0000000..a5efc14
--- /dev/null
+++ b/java/com/google/gerrit/entities/ImmutableConfig.java
@@ -0,0 +1,91 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Immutable parsed representation of a {@link org.eclipse.jgit.lib.Config} that can be cached.
+ * Supports only a limited set of operations.
+ */
+public class ImmutableConfig {
+  public static final ImmutableConfig EMPTY = new ImmutableConfig("", new Config());
+
+  private final String stringCfg;
+  private final Config cfg;
+
+  private ImmutableConfig(String stringCfg, Config cfg) {
+    this.stringCfg = stringCfg;
+    this.cfg = cfg;
+  }
+
+  public static ImmutableConfig parse(String stringCfg) throws ConfigInvalidException {
+    Config cfg = new Config();
+    cfg.fromText(stringCfg);
+    return new ImmutableConfig(stringCfg, cfg);
+  }
+
+  /** Returns a mutable copy of this config. */
+  public Config mutableCopy() {
+    Config cfg = new Config();
+    try {
+      cfg.fromText(this.cfg.toText());
+    } catch (ConfigInvalidException e) {
+      // Can't happen as we used JGit to format that config.
+      throw new IllegalStateException(e);
+    }
+    return cfg;
+  }
+
+  /** @see Config#getSections() */
+  public Set<String> getSections() {
+    return cfg.getSections();
+  }
+
+  /** @see Config#getNames(String) */
+  public Set<String> getNames(String section) {
+    return cfg.getNames(section);
+  }
+
+  /** @see Config#getNames(String, String) */
+  public Set<String> getNames(String section, String subsection) {
+    return cfg.getNames(section, subsection);
+  }
+
+  /** @see Config#getStringList(String, String, String) */
+  public String[] getStringList(String section, String subsection, String name) {
+    return cfg.getStringList(section, subsection, name);
+  }
+
+  /** @see Config#getSubsections(String) */
+  public Set<String> getSubsections(String section) {
+    return cfg.getSubsections(section);
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof ImmutableConfig)) {
+      return false;
+    }
+    return ((ImmutableConfig) o).stringCfg.equals(stringCfg);
+  }
+
+  @Override
+  public int hashCode() {
+    return stringCfg.hashCode();
+  }
+}
diff --git a/java/com/google/gerrit/entities/LabelId.java b/java/com/google/gerrit/entities/LabelId.java
index 1cc45c8..2426818 100644
--- a/java/com/google/gerrit/entities/LabelId.java
+++ b/java/com/google/gerrit/entities/LabelId.java
@@ -18,7 +18,9 @@
 
 @AutoValue
 public abstract class LabelId {
-  static final String LEGACY_SUBMIT_NAME = "SUBM";
+  public static final String LEGACY_SUBMIT_NAME = "SUBM";
+  public static final String CODE_REVIEW = "Code-Review";
+  public static final String VERIFIED = "Verified";
 
   public static LabelId create(String n) {
     return new AutoValue_LabelId(n);
diff --git a/java/com/google/gerrit/entities/LabelType.java b/java/com/google/gerrit/entities/LabelType.java
index a8d4da5..9649642 100644
--- a/java/com/google/gerrit/entities/LabelType.java
+++ b/java/com/google/gerrit/entities/LabelType.java
@@ -29,6 +29,7 @@
 public abstract class LabelType {
   public static final boolean DEF_ALLOW_POST_SUBMIT = true;
   public static final boolean DEF_CAN_OVERRIDE = true;
+  public static final boolean DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE = false;
   public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CHANGE = true;
   public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = false;
   public static final boolean DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = false;
@@ -101,6 +102,8 @@
 
   public abstract boolean isCopyMaxScore();
 
+  public abstract boolean isCopyAllScoresIfListOfFilesDidNotChange();
+
   public abstract boolean isCopyAllScoresOnMergeFirstParentUpdate();
 
   public abstract boolean isCopyAllScoresOnTrivialRebase();
@@ -143,6 +146,8 @@
         .setMaxNegative(Short.MIN_VALUE)
         .setMaxPositive(Short.MAX_VALUE)
         .setCanOverride(DEF_CAN_OVERRIDE)
+        .setCopyAllScoresIfListOfFilesDidNotChange(
+            DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE)
         .setCopyAllScoresIfNoChange(DEF_COPY_ALL_SCORES_IF_NO_CHANGE)
         .setCopyAllScoresIfNoCodeChange(DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE)
         .setCopyAllScoresOnTrivialRebase(DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE)
@@ -238,6 +243,9 @@
 
     public abstract Builder setCopyMaxScore(boolean copyMaxScore);
 
+    public abstract Builder setCopyAllScoresIfListOfFilesDidNotChange(
+        boolean copyAllScoresIfListOfFilesDidNotChange);
+
     public abstract Builder setCopyAllScoresOnMergeFirstParentUpdate(
         boolean copyAllScoresOnMergeFirstParentUpdate);
 
diff --git a/java/com/google/gerrit/entities/Patch.java b/java/com/google/gerrit/entities/Patch.java
index e6b2167..856765b 100644
--- a/java/com/google/gerrit/entities/Patch.java
+++ b/java/com/google/gerrit/entities/Patch.java
@@ -160,5 +160,40 @@
     }
   }
 
+  /**
+   * Constants describing various file modes recognized by GIT. This is the Gerrit entity for {@link
+   * org.eclipse.jgit.lib.FileMode}.
+   */
+  public enum FileMode implements CodedEnum {
+    /** Mode indicating an entry is a tree (aka directory). */
+    TREE('T'),
+
+    /** Mode indicating an entry is a symbolic link. */
+    SYMLINK('S'),
+
+    /** Mode indicating an entry is a non-executable file. */
+    REGULAR_FILE('R'),
+
+    /** Mode indicating an entry is an executable file. */
+    EXECUTABLE_FILE('E'),
+
+    /** Mode indicating an entry is a submodule commit in another repository. */
+    GITLINK('G'),
+
+    /** Mode indicating an entry is missing during parallel walks. */
+    MISSING('M');
+
+    private final char code;
+
+    FileMode(char c) {
+      code = c;
+    }
+
+    @Override
+    public char getCode() {
+      return code;
+    }
+  }
+
   private Patch() {}
 }
diff --git a/java/com/google/gerrit/entities/Permission.java b/java/com/google/gerrit/entities/Permission.java
index 3f04fa5..322c79e 100644
--- a/java/com/google/gerrit/entities/Permission.java
+++ b/java/com/google/gerrit/entities/Permission.java
@@ -140,6 +140,7 @@
     return true;
   }
 
+  /** The permission name, eg. {@code Permission.SUBMIT} */
   public abstract String getName();
 
   protected abstract boolean isExclusiveGroup();
diff --git a/java/com/google/gerrit/entities/RefNames.java b/java/com/google/gerrit/entities/RefNames.java
index 5595bc7..2263aba 100644
--- a/java/com/google/gerrit/entities/RefNames.java
+++ b/java/com/google/gerrit/entities/RefNames.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.entities;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.UsedAt;
 
 /** Constants and utilities for Gerrit-specific ref names. */
@@ -105,6 +106,26 @@
   /** A change starred by a user */
   public static final String REFS_STARRED_CHANGES = "refs/starred-changes/";
 
+  /**
+   * List of refs managed by Gerrit. Covers all Gerrit internal refs.
+   *
+   * <p><b>Caution</b> Any ref not in this list will be served if the user was granted a READ
+   * permission on it using Gerrit's permission model.
+   */
+  public static final ImmutableList<String> GERRIT_REFS =
+      ImmutableList.of(
+          REFS_CHANGES,
+          REFS_EXTERNAL_IDS,
+          REFS_CACHE_AUTOMERGE,
+          REFS_DRAFT_COMMENTS,
+          REFS_DELETED_GROUPS,
+          REFS_SEQUENCES,
+          REFS_GROUPS,
+          REFS_GROUPNAMES,
+          REFS_USERS,
+          REFS_STARRED_CHANGES,
+          REFS_REJECT_COMMITS);
+
   public static String fullName(String ref) {
     return (ref.startsWith(REFS) || ref.equals(HEAD)) ? ref : REFS_HEADS + ref;
   }
@@ -118,6 +139,11 @@
     return ref;
   }
 
+  /**
+   * Warning: Change refs have to manually be advertised in {@code
+   * com.google.gerrit.server.permissions.DefaultRefFilter}; this should be done when adding new
+   * change refs.
+   */
   public static String changeMetaRef(Change.Id id) {
     StringBuilder r = newStringBuilder().append(REFS_CHANGES);
     return shard(id.get(), r).append(META_SUFFIX).toString();
@@ -255,6 +281,10 @@
     return ref.startsWith(REFS_USERS);
   }
 
+  public static boolean isRefsUsersSelf(String ref, boolean isAllUsers) {
+    return isAllUsers && REFS_USERS_SELF.equals(ref);
+  }
+
   /**
    * Whether the ref is a group branch that stores NoteDb data of a group. Returns {@code true} for
    * all refs that start with {@code refs/groups/}.
@@ -271,6 +301,16 @@
     return ref.startsWith(REFS_DELETED_GROUPS);
   }
 
+  /** Returns true if the provided ref is for draft comments. */
+  public static boolean isRefsDraftsComments(String ref) {
+    return ref.startsWith(REFS_DRAFT_COMMENTS);
+  }
+
+  /** Returns true if the provided ref is for starred changes. */
+  public static boolean isRefsStarredChanges(String ref) {
+    return ref.startsWith(REFS_STARRED_CHANGES);
+  }
+
   /**
    * Whether the ref is used for storing group data in NoteDb. Returns {@code true} for all group
    * branches, refs/meta/group-names and deleted group branches.
@@ -292,21 +332,11 @@
    * <p>Any ref for which this method evaluates to true will be served to users who have the {@code
    * ACCESS_DATABASE} capability.
    *
-   * <p><b>Caution</b>Any ref not in this list will be served if the user was granted a READ
+   * <p><b>Caution</b> Any ref not in this list will be served if the user was granted a READ
    * permission on it using Gerrit's permission model.
    */
   public static boolean isGerritRef(String ref) {
-    return ref.startsWith(REFS_CHANGES)
-        || ref.startsWith(REFS_EXTERNAL_IDS)
-        || ref.startsWith(REFS_CACHE_AUTOMERGE)
-        || ref.startsWith(REFS_DRAFT_COMMENTS)
-        || ref.startsWith(REFS_DELETED_GROUPS)
-        || ref.startsWith(REFS_SEQUENCES)
-        || ref.startsWith(REFS_GROUPS)
-        || ref.startsWith(REFS_GROUPNAMES)
-        || ref.startsWith(REFS_USERS)
-        || ref.startsWith(REFS_STARRED_CHANGES)
-        || ref.startsWith(REFS_REJECT_COMMITS);
+    return GERRIT_REFS.stream().anyMatch(internalRef -> ref.startsWith(internalRef));
   }
 
   static Integer parseShardedRefPart(String name) {
diff --git a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java b/java/com/google/gerrit/exceptions/InternalServerWithUserMessageException.java
similarity index 62%
rename from java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java
rename to java/com/google/gerrit/exceptions/InternalServerWithUserMessageException.java
index 08d6ce7..452192c 100644
--- a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java
+++ b/java/com/google/gerrit/exceptions/InternalServerWithUserMessageException.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2019 The Android Open Source Project
+// Copyright (C) 2021 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,12 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.change;
+package com.google.gerrit.exceptions;
 
-import com.google.gerrit.extensions.common.PluginDefinedInfo;
-import com.google.gerrit.server.query.change.ChangeData;
-import java.util.List;
+public class InternalServerWithUserMessageException extends RuntimeException {
+  private static final long serialVersionUID = 1L;
 
-public interface PluginDefinedAttributesFactory {
-  List<PluginDefinedInfo> create(ChangeData cd);
+  public InternalServerWithUserMessageException(String msg, Throwable cause) {
+    super(msg, cause);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/BUILD b/java/com/google/gerrit/extensions/BUILD
index da5dc8b..21949f7 100644
--- a/java/com/google/gerrit/extensions/BUILD
+++ b/java/com/google/gerrit/extensions/BUILD
@@ -1,5 +1,5 @@
 load("@rules_java//java:defs.bzl", "java_binary", "java_library")
-load("//lib:guava.bzl", "GUAVA_DOC_URL")
+load("//tools:nongoogle.bzl", "GUAVA_DOC_URL")
 load("//tools/bzl:javadoc.bzl", "java_doc")
 
 _DOC_VERS = "5.5.0.201909110433-r"
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 3364fc1..6c15c0c 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -413,6 +413,7 @@
 
   abstract class CommentsRequest {
     private boolean enableContext;
+    private int contextPadding;
 
     /**
      * Get all published comments on a change.
@@ -436,6 +437,11 @@
       return this;
     }
 
+    public CommentsRequest contextPadding(int contextPadding) {
+      this.contextPadding = contextPadding;
+      return this;
+    }
+
     public CommentsRequest withContext() {
       this.enableContext = true;
       return this;
@@ -444,6 +450,10 @@
     public boolean getContext() {
       return enableContext;
     }
+
+    public int getContextPadding() {
+      return contextPadding;
+    }
   }
 
   abstract class SuggestedReviewersRequest {
diff --git a/java/com/google/gerrit/extensions/api/changes/MoveInput.java b/java/com/google/gerrit/extensions/api/changes/MoveInput.java
index 795642a..3d82990 100644
--- a/java/com/google/gerrit/extensions/api/changes/MoveInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/MoveInput.java
@@ -17,4 +17,14 @@
 public class MoveInput {
   public String message;
   public String destinationBranch;
+  /**
+   * Whether or not to keep all votes in the destination branch. Keeping the votes can be confusing
+   * in the context of the destination branch, see
+   * https://gerrit-review.googlesource.com/c/gerrit/+/129171. That is why only the users with
+   * {@link com.google.gerrit.server.permissions.GlobalPermission#ADMINISTRATE_SERVER} permissions
+   * can use this option.
+   *
+   * <p>By default, only the veto votes that are blocking the change from submission are moved.
+   */
+  public boolean keepAllVotes;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/RebaseInput.java b/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
index 5f4a014..10559a3 100644
--- a/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
@@ -16,4 +16,12 @@
 
 public class RebaseInput {
   public String base;
+
+  /**
+   * Whether the rebase should succeed if there are conflicts.
+   *
+   * <p>If there are conflicts the file contents of the rebased change contain git conflict markers
+   * to indicate the conflicts.
+   */
+  public boolean allowConflicts;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
index b419c2f..73e6a4e 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -68,6 +68,8 @@
 
   ChangeApi rebase(RebaseInput in) throws RestApiException;
 
+  ChangeInfo rebaseAsInfo(RebaseInput in) throws RestApiException;
+
   boolean canRebase() throws RestApiException;
 
   RevisionReviewerApi reviewer(String id) throws RestApiException;
@@ -218,6 +220,11 @@
     }
 
     @Override
+    public ChangeInfo rebaseAsInfo(RebaseInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public boolean canRebase() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java b/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java
index fab2ec4..423ac49 100644
--- a/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java
+++ b/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java
@@ -14,10 +14,15 @@
 
 package com.google.gerrit.extensions.api.config;
 
+import java.util.List;
+
 public class AccessCheckInfo {
   public String message;
   // HTTP status code
   public int status;
 
+  /** Debug logs that may help to understand why a permission is denied or allowed. */
+  public List<String> debugLogs;
+
   // for future extension, we may add inputs / results for bulk checks.
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/CommitApi.java b/java/com/google/gerrit/extensions/api/projects/CommitApi.java
index a53fc74..b0cc9da 100644
--- a/java/com/google/gerrit/extensions/api/projects/CommitApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/CommitApi.java
@@ -18,8 +18,10 @@
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.IncludedInInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import java.util.Map;
 
 public interface CommitApi {
   CommitInfo get() throws RestApiException;
@@ -28,6 +30,9 @@
 
   IncludedInInfo includedIn() throws RestApiException;
 
+  /** List files in a specific commit against the parent commit. */
+  Map<String, FileInfo> files(int parentNum) throws RestApiException;
+
   /** A default implementation for source compatibility when adding new methods to the interface. */
   class NotImplemented implements CommitApi {
     @Override
@@ -44,5 +49,10 @@
     public IncludedInInfo includedIn() throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public Map<String, FileInfo> files(int parentNum) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index 30514a6..21b319e 100644
--- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -27,11 +27,12 @@
 
   /** Preferred method to download a change. */
   public enum DownloadCommand {
-    REPO_DOWNLOAD,
     PULL,
     CHECKOUT,
     CHERRY_PICK,
-    FORMAT_PATCH
+    FORMAT_PATCH,
+    BRANCH,
+    RESET,
   }
 
   public enum DateFormat {
@@ -146,6 +147,7 @@
   public EmailFormat emailFormat;
   public DefaultBase defaultBaseForMerges;
   public Boolean publishCommentsOnPush;
+  public Boolean disableKeyboardShortcuts;
   public Boolean workInProgressByDefault;
   public List<MenuItem> my;
   public List<String> changeTable;
@@ -204,6 +206,7 @@
     p.emailFormat = EmailFormat.HTML_PLAINTEXT;
     p.defaultBaseForMerges = DefaultBase.FIRST_PARENT;
     p.publishCommentsOnPush = false;
+    p.disableKeyboardShortcuts = false;
     p.workInProgressByDefault = false;
     return p;
   }
diff --git a/java/com/google/gerrit/extensions/common/ActionInfo.java b/java/com/google/gerrit/extensions/common/ActionInfo.java
index 6ab80b2..2144ed5 100644
--- a/java/com/google/gerrit/extensions/common/ActionInfo.java
+++ b/java/com/google/gerrit/extensions/common/ActionInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import com.google.gerrit.extensions.webui.UiAction;
+import java.util.Objects;
 
 /**
  * Representation of an action in the REST API.
@@ -55,4 +56,23 @@
     title = d.getTitle();
     enabled = d.isEnabled() ? true : null;
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof ActionInfo) {
+      ActionInfo actionInfo = (ActionInfo) o;
+      return Objects.equals(method, actionInfo.method)
+          && Objects.equals(label, actionInfo.label)
+          && Objects.equals(title, actionInfo.title)
+          && Objects.equals(enabled, actionInfo.enabled);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(method, label, title, enabled);
+  }
+
+  protected ActionInfo() {}
 }
diff --git a/java/com/google/gerrit/extensions/common/ApprovalInfo.java b/java/com/google/gerrit/extensions/common/ApprovalInfo.java
index f95ddff..bf72e83 100644
--- a/java/com/google/gerrit/extensions/common/ApprovalInfo.java
+++ b/java/com/google/gerrit/extensions/common/ApprovalInfo.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.common.Nullable;
 import java.sql.Timestamp;
+import java.util.Objects;
 
 /**
  * Representation of an approval in the REST API.
@@ -71,4 +72,23 @@
     this.date = date;
     this.tag = tag;
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof ApprovalInfo) {
+      ApprovalInfo approvalInfo = (ApprovalInfo) o;
+      return super.equals(o)
+          && Objects.equals(tag, approvalInfo.tag)
+          && Objects.equals(value, approvalInfo.value)
+          && Objects.equals(date, approvalInfo.date)
+          && Objects.equals(postSubmit, approvalInfo.postSubmit)
+          && Objects.equals(permittedVotingRange, approvalInfo.permittedVotingRange);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(super.hashCode(), tag, value, date, postSubmit, permittedVotingRange);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/AttentionSetInfo.java b/java/com/google/gerrit/extensions/common/AttentionSetInfo.java
index f29d32b..ba865fb 100644
--- a/java/com/google/gerrit/extensions/common/AttentionSetInfo.java
+++ b/java/com/google/gerrit/extensions/common/AttentionSetInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import java.sql.Timestamp;
+import java.util.Objects;
 
 /**
  * Represents a single user included in the attention set. Used in the API. See {@link
@@ -36,4 +37,22 @@
     this.lastUpdate = lastUpdate;
     this.reason = reason;
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof AttentionSetInfo) {
+      AttentionSetInfo attentionSetInfo = (AttentionSetInfo) o;
+      return Objects.equals(account, attentionSetInfo.account)
+          && Objects.equals(lastUpdate, attentionSetInfo.lastUpdate)
+          && Objects.equals(reason, attentionSetInfo.reason);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(account, lastUpdate, reason);
+  }
+
+  protected AttentionSetInfo() {}
 }
diff --git a/java/com/google/gerrit/extensions/common/AvatarInfo.java b/java/com/google/gerrit/extensions/common/AvatarInfo.java
index 75665a8..b620ac2 100644
--- a/java/com/google/gerrit/extensions/common/AvatarInfo.java
+++ b/java/com/google/gerrit/extensions/common/AvatarInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.common;
 
+import java.util.Objects;
+
 /**
  * Representation of an avatar in the REST API.
  *
@@ -38,4 +40,20 @@
 
   /** The width of the avatar image in pixels. */
   public Integer width;
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof AvatarInfo) {
+      AvatarInfo avatarInfo = (AvatarInfo) o;
+      return Objects.equals(url, avatarInfo.url)
+          && Objects.equals(height, avatarInfo.height)
+          && Objects.equals(width, avatarInfo.width);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(url, height, width);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java b/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
index a441bfd..b387017 100644
--- a/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
@@ -19,7 +19,6 @@
   public Boolean allowBlame;
   public Boolean showAssigneeInChangesTable;
   public Boolean disablePrivateChanges;
-  public int largeChange;
   public String replyLabel;
   public String replyTooltip;
   public int updateDelay;
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index 190a97e..528efe3 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -70,6 +70,7 @@
   public String submissionId;
   public Integer cherryPickOfChange;
   public Integer cherryPickOfPatchSet;
+  public String metaRevId;
 
   /**
    * Whether the change contains conflicts.
@@ -83,7 +84,8 @@
    * com.google.gerrit.server.restapi.change.CreateChange}, {@link
    * com.google.gerrit.server.restapi.change.CreateMergePatchSet}, {@link
    * com.google.gerrit.server.restapi.change.CherryPick}, {@link
-   * com.google.gerrit.server.restapi.change.CherryPickCommit}
+   * com.google.gerrit.server.restapi.change.CherryPickCommit}, {@link
+   * com.google.gerrit.server.restapi.change.Rebase}
    */
   public Boolean containsGitConflicts;
 
diff --git a/java/com/google/gerrit/extensions/common/FetchInfo.java b/java/com/google/gerrit/extensions/common/FetchInfo.java
index eda84b1..4b1e941 100644
--- a/java/com/google/gerrit/extensions/common/FetchInfo.java
+++ b/java/com/google/gerrit/extensions/common/FetchInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import java.util.Map;
+import java.util.Objects;
 
 public class FetchInfo {
   public String url;
@@ -25,4 +26,22 @@
     this.url = url;
     this.ref = ref;
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof FetchInfo) {
+      FetchInfo fetchInfo = (FetchInfo) o;
+      return Objects.equals(url, fetchInfo.url)
+          && Objects.equals(ref, fetchInfo.ref)
+          && Objects.equals(commands, fetchInfo.commands);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(url, ref, commands);
+  }
+
+  protected FetchInfo() {}
 }
diff --git a/java/com/google/gerrit/extensions/common/FileInfo.java b/java/com/google/gerrit/extensions/common/FileInfo.java
index 32c5bd5..510c2ad 100644
--- a/java/com/google/gerrit/extensions/common/FileInfo.java
+++ b/java/com/google/gerrit/extensions/common/FileInfo.java
@@ -34,8 +34,8 @@
           && Objects.equals(oldPath, fileInfo.oldPath)
           && Objects.equals(linesInserted, fileInfo.linesInserted)
           && Objects.equals(linesDeleted, fileInfo.linesDeleted)
-          && Objects.equals(sizeDelta, fileInfo.sizeDelta)
-          && Objects.equals(size, fileInfo.size);
+          && sizeDelta == fileInfo.sizeDelta
+          && size == fileInfo.size;
     }
     return false;
   }
diff --git a/java/com/google/gerrit/extensions/common/GerritInfo.java b/java/com/google/gerrit/extensions/common/GerritInfo.java
index 2ae6703..3265a00 100644
--- a/java/com/google/gerrit/extensions/common/GerritInfo.java
+++ b/java/com/google/gerrit/extensions/common/GerritInfo.java
@@ -23,4 +23,5 @@
   public Boolean editGpgKeys;
   public String reportBugUrl;
   public String primaryWeblinkName;
+  public String instanceId;
 }
diff --git a/java/com/google/gerrit/extensions/common/GpgKeyInfo.java b/java/com/google/gerrit/extensions/common/GpgKeyInfo.java
index 7a5c15b..d656f22 100644
--- a/java/com/google/gerrit/extensions/common/GpgKeyInfo.java
+++ b/java/com/google/gerrit/extensions/common/GpgKeyInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import java.util.List;
+import java.util.Objects;
 
 public class GpgKeyInfo {
   /**
@@ -43,4 +44,22 @@
 
   public Status status;
   public List<String> problems;
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof GpgKeyInfo) {
+      GpgKeyInfo gpgKeyInfo = (GpgKeyInfo) o;
+      return Objects.equals(id, gpgKeyInfo.id)
+          && Objects.equals(fingerprint, gpgKeyInfo.fingerprint)
+          && Objects.equals(userIds, gpgKeyInfo.userIds)
+          && Objects.equals(status, gpgKeyInfo.status)
+          && Objects.equals(problems, gpgKeyInfo.problems);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(id, fingerprint, userIds, status, problems);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java b/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
index f552566..9a6d086 100644
--- a/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
+++ b/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
@@ -28,6 +28,7 @@
   public Boolean copyAnyScore;
   public Boolean copyMinScore;
   public Boolean copyMaxScore;
+  public Boolean copyAllScoresIfListOfFilesDidNotChange;
   public Boolean copyAllScoresIfNoChange;
   public Boolean copyAllScoresIfNoCodeChange;
   public Boolean copyAllScoresOnTrivialRebase;
diff --git a/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java b/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
index 23d5df1..87cae86 100644
--- a/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
+++ b/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
@@ -27,6 +27,7 @@
   public Boolean copyAnyScore;
   public Boolean copyMinScore;
   public Boolean copyMaxScore;
+  public Boolean copyAllScoresIfListOfFilesDidNotChange;
   public Boolean copyAllScoresIfNoChange;
   public Boolean copyAllScoresIfNoCodeChange;
   public Boolean copyAllScoresOnTrivialRebase;
diff --git a/java/com/google/gerrit/extensions/common/LabelInfo.java b/java/com/google/gerrit/extensions/common/LabelInfo.java
index 76dd93d..44bcdaf 100644
--- a/java/com/google/gerrit/extensions/common/LabelInfo.java
+++ b/java/com/google/gerrit/extensions/common/LabelInfo.java
@@ -16,6 +16,7 @@
 
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 
 public class LabelInfo {
   public AccountInfo approved;
@@ -30,4 +31,37 @@
   public Short defaultValue;
   public Boolean optional;
   public Boolean blocking;
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof LabelInfo) {
+      LabelInfo labelInfo = (LabelInfo) o;
+      return Objects.equals(approved, labelInfo.approved)
+          && Objects.equals(rejected, labelInfo.rejected)
+          && Objects.equals(recommended, labelInfo.recommended)
+          && Objects.equals(disliked, labelInfo.disliked)
+          && Objects.equals(all, labelInfo.all)
+          && Objects.equals(values, labelInfo.values)
+          && Objects.equals(value, labelInfo.value)
+          && Objects.equals(defaultValue, labelInfo.defaultValue)
+          && Objects.equals(optional, labelInfo.optional)
+          && Objects.equals(blocking, labelInfo.blocking);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(
+        approved,
+        rejected,
+        recommended,
+        disliked,
+        all,
+        values,
+        value,
+        defaultValue,
+        optional,
+        blocking);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java b/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java
index 69bfa2c..e2b1c36 100644
--- a/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java
+++ b/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java
@@ -14,7 +14,24 @@
 
 package com.google.gerrit.extensions.common;
 
+import java.util.Objects;
+
 public class PluginDefinedInfo {
   public String name;
   public String message;
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof PluginDefinedInfo) {
+      PluginDefinedInfo pluginDefinedInfo = (PluginDefinedInfo) o;
+      return Objects.equals(name, pluginDefinedInfo.name)
+          && Objects.equals(message, pluginDefinedInfo.message);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(name, message);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/PushCertificateInfo.java b/java/com/google/gerrit/extensions/common/PushCertificateInfo.java
index 9eed808..199dbd1 100644
--- a/java/com/google/gerrit/extensions/common/PushCertificateInfo.java
+++ b/java/com/google/gerrit/extensions/common/PushCertificateInfo.java
@@ -14,7 +14,24 @@
 
 package com.google.gerrit.extensions.common;
 
+import java.util.Objects;
+
 public class PushCertificateInfo {
   public String certificate;
   public GpgKeyInfo key;
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof PushCertificateInfo) {
+      PushCertificateInfo pushCertificateInfo = (PushCertificateInfo) o;
+      return Objects.equals(certificate, pushCertificateInfo.certificate)
+          && Objects.equals(key, pushCertificateInfo.key);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(certificate, key);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java b/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java
index eccdc64..37e1ceb 100644
--- a/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java
+++ b/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java
@@ -16,10 +16,28 @@
 
 import com.google.gerrit.extensions.client.ReviewerState;
 import java.sql.Timestamp;
+import java.util.Objects;
 
 public class ReviewerUpdateInfo {
   public Timestamp updated;
   public AccountInfo updatedBy;
   public AccountInfo reviewer;
   public ReviewerState state;
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof ReviewerUpdateInfo) {
+      ReviewerUpdateInfo reviewerUpdateInfo = (ReviewerUpdateInfo) o;
+      return Objects.equals(updated, reviewerUpdateInfo.updated)
+          && Objects.equals(updatedBy, reviewerUpdateInfo.updatedBy)
+          && Objects.equals(reviewer, reviewerUpdateInfo.reviewer)
+          && Objects.equals(state, reviewerUpdateInfo.state);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(updated, updatedBy, reviewer, state);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/RevisionInfo.java b/java/com/google/gerrit/extensions/common/RevisionInfo.java
index f262901..ea61f31 100644
--- a/java/com/google/gerrit/extensions/common/RevisionInfo.java
+++ b/java/com/google/gerrit/extensions/common/RevisionInfo.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.extensions.client.ChangeKind;
 import java.sql.Timestamp;
 import java.util.Map;
+import java.util.Objects;
 
 public class RevisionInfo {
   // ActionJson#copy(List, RevisionInfo) must be adapted if new fields are added that are not
@@ -34,4 +35,43 @@
   public String commitWithFooters;
   public PushCertificateInfo pushCertificate;
   public String description;
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof RevisionInfo) {
+      RevisionInfo revisionInfo = (RevisionInfo) o;
+      return isCurrent == revisionInfo.isCurrent
+          && Objects.equals(kind, revisionInfo.kind)
+          && _number == revisionInfo._number
+          && Objects.equals(created, revisionInfo.created)
+          && Objects.equals(uploader, revisionInfo.uploader)
+          && Objects.equals(ref, revisionInfo.ref)
+          && Objects.equals(fetch, revisionInfo.fetch)
+          && Objects.equals(commit, revisionInfo.commit)
+          && Objects.equals(files, revisionInfo.files)
+          && Objects.equals(actions, revisionInfo.actions)
+          && Objects.equals(commitWithFooters, revisionInfo.commitWithFooters)
+          && Objects.equals(pushCertificate, revisionInfo.pushCertificate)
+          && Objects.equals(description, revisionInfo.description);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(
+        isCurrent,
+        kind,
+        _number,
+        created,
+        uploader,
+        ref,
+        fetch,
+        commit,
+        files,
+        actions,
+        commitWithFooters,
+        pushCertificate,
+        description);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java b/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java
index 3483de5..a13e645 100644
--- a/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java
+++ b/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java
@@ -18,9 +18,9 @@
 import java.util.Objects;
 
 public class SubmitRequirementInfo {
-  public final String status;
-  public final String fallbackText;
-  public final String type;
+  public String status;
+  public String fallbackText;
+  public String type;
 
   public SubmitRequirementInfo(String status, String fallbackText, String type) {
     this.status = status;
@@ -55,4 +55,6 @@
         .add("type", type)
         .toString();
   }
+
+  protected SubmitRequirementInfo() {}
 }
diff --git a/java/com/google/gerrit/extensions/common/TrackingIdInfo.java b/java/com/google/gerrit/extensions/common/TrackingIdInfo.java
index 0c5ed68..3d35e08 100644
--- a/java/com/google/gerrit/extensions/common/TrackingIdInfo.java
+++ b/java/com/google/gerrit/extensions/common/TrackingIdInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.common;
 
+import java.util.Objects;
+
 public class TrackingIdInfo {
   public String system;
   public String id;
@@ -22,4 +24,20 @@
     this.system = system;
     this.id = id;
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof TrackingIdInfo) {
+      TrackingIdInfo trackingIdInfo = (TrackingIdInfo) o;
+      return Objects.equals(system, trackingIdInfo.system) && Objects.equals(id, trackingIdInfo.id);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(system, id);
+  }
+
+  protected TrackingIdInfo() {}
 }
diff --git a/java/com/google/gerrit/extensions/common/VotingRangeInfo.java b/java/com/google/gerrit/extensions/common/VotingRangeInfo.java
index 5c35a49..2f7e9e4 100644
--- a/java/com/google/gerrit/extensions/common/VotingRangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/VotingRangeInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.common;
 
+import java.util.Objects;
+
 public class VotingRangeInfo {
   public int min;
   public int max;
@@ -22,4 +24,18 @@
     this.min = min;
     this.max = max;
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof VotingRangeInfo) {
+      VotingRangeInfo votingRangeInfo = (VotingRangeInfo) o;
+      return min == votingRangeInfo.min && max == votingRangeInfo.max;
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(min, max);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/WebLinkInfo.java b/java/com/google/gerrit/extensions/common/WebLinkInfo.java
index 84fd970..ba12be0 100644
--- a/java/com/google/gerrit/extensions/common/WebLinkInfo.java
+++ b/java/com/google/gerrit/extensions/common/WebLinkInfo.java
@@ -64,4 +64,6 @@
         + target
         + "}";
   }
+
+  protected WebLinkInfo() {}
 }
diff --git a/java/com/google/gerrit/extensions/restapi/NeedsParams.java b/java/com/google/gerrit/extensions/restapi/NeedsParams.java
index c6e2151..bb4294f 100644
--- a/java/com/google/gerrit/extensions/restapi/NeedsParams.java
+++ b/java/com/google/gerrit/extensions/restapi/NeedsParams.java
@@ -19,7 +19,9 @@
 /**
  * Optional interface for {@link RestCollection}.
  *
- * <p>Collections that implement this interface can get to know about the request parameters.
+ * <p>Collections that implement this interface can get to know about the request parameters. The
+ * request parameters are passed only if the collection is the endpoint, e.g. {@code
+ * /changes/?q=abc} would trigger, but {@code /changes/100/?q=abc} does not.
  */
 public interface NeedsParams {
   /**
diff --git a/java/com/google/gerrit/extensions/webui/ParentWebLink.java b/java/com/google/gerrit/extensions/webui/ParentWebLink.java
index dfc970d..9f2bc6e 100644
--- a/java/com/google/gerrit/extensions/webui/ParentWebLink.java
+++ b/java/com/google/gerrit/extensions/webui/ParentWebLink.java
@@ -30,10 +30,13 @@
    *
    * <p>
    *
-   * @param projectName Name of the project
-   * @param commit Commit sha1 of the parent revision
+   * @param projectName name of the project
+   * @param commit commit sha1 of the parent revision
+   * @param commitMessage the commit messsage of the change
+   * @param branchName target branch of the change
    * @return WebLinkInfo that links to parent commit in external service, null if there should be no
    *     link.
    */
-  WebLinkInfo getParentWebLink(String projectName, String commit);
+  WebLinkInfo getParentWebLink(
+      String projectName, String commit, String commitMessage, String branchName);
 }
diff --git a/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java b/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
index 93fe8e1..0e8e28e 100644
--- a/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
+++ b/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
@@ -30,10 +30,13 @@
    *
    * <p>
    *
-   * @param projectName Name of the project
-   * @param commit Commit of the patch set
+   * @param projectName name of the project
+   * @param commit commit of the patch set
+   * @param commitMessage the commit message of the change
+   * @param branchName target branch of the change
    * @return WebLinkInfo that links to patch set in external service, null if there should be no
    *     link.
    */
-  WebLinkInfo getPatchSetWebLink(String projectName, String commit);
+  WebLinkInfo getPatchSetWebLink(
+      String projectName, String commit, String commitMessage, String branchName);
 }
diff --git a/java/com/google/gerrit/httpd/CacheBasedWebSession.java b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
index 5c4830c..a3a67e5 100644
--- a/java/com/google/gerrit/httpd/CacheBasedWebSession.java
+++ b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PropertyMap;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AuthResult;
 import com.google.gerrit.server.account.externalids.ExternalId;
@@ -161,15 +162,11 @@
   }
 
   @Override
-  public ExternalId.Key getLastLoginExternalId() {
-    return val != null ? val.getExternalId() : null;
-  }
-
-  @Override
   public CurrentUser getUser() {
     if (user == null) {
       if (isSignedIn()) {
-        user = identified.create(val.getAccountId());
+
+        user = identified.create(val.getAccountId(), getUserProperties(val));
       } else {
         user = anonymousProvider.get();
       }
@@ -177,6 +174,15 @@
     return user;
   }
 
+  private static PropertyMap getUserProperties(@Nullable WebSessionManager.Val val) {
+    if (val == null || val.getExternalId() == null) {
+      return PropertyMap.EMPTY;
+    }
+    return PropertyMap.builder()
+        .put(CurrentUser.LAST_LOGIN_EXTERNAL_ID_PROPERTY_KEY, val.getExternalId())
+        .build();
+  }
+
   @Override
   public void login(AuthResult res, boolean rememberMe) {
     Account.Id id = res.getAccountId();
@@ -194,7 +200,7 @@
     key = manager.createKey(id);
     val = manager.createVal(key, id, rememberMe, identity, null, null);
     saveCookie();
-    user = identified.create(val.getAccountId());
+    user = identified.create(val.getAccountId(), getUserProperties(val));
   }
 
   /** Set the user account for this current request only. */
@@ -202,7 +208,7 @@
   public void setUserAccountId(Account.Id id) {
     key = new Key("id:" + id);
     val = new Val(id, 0, false, null, 0, null, null);
-    user = identified.runAs(id, user);
+    user = identified.runAs(id, user, PropertyMap.EMPTY);
   }
 
   @Override
diff --git a/java/com/google/gerrit/httpd/HttpLogoutServlet.java b/java/com/google/gerrit/httpd/HttpLogoutServlet.java
index 1eaaba3..b56f973 100644
--- a/java/com/google/gerrit/httpd/HttpLogoutServlet.java
+++ b/java/com/google/gerrit/httpd/HttpLogoutServlet.java
@@ -73,11 +73,10 @@
 
   @Override
   protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
-
-    final String sid = webSession.get().getSessionId();
-    final CurrentUser currentUser = webSession.get().getUser();
-    final String what = "sign out";
-    final long when = TimeUtil.nowMs();
+    String sid = webSession.get().getSessionId();
+    CurrentUser currentUser = webSession.get().getUser();
+    String what = "sign out";
+    long when = TimeUtil.nowMs();
 
     try {
       doLogout(req, rsp);
diff --git a/java/com/google/gerrit/httpd/WebSession.java b/java/com/google/gerrit/httpd/WebSession.java
index e8b54fe..daf30ff 100644
--- a/java/com/google/gerrit/httpd/WebSession.java
+++ b/java/com/google/gerrit/httpd/WebSession.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AuthResult;
-import com.google.gerrit.server.account.externalids.ExternalId;
 
 public interface WebSession {
   boolean isSignedIn();
@@ -29,8 +28,6 @@
 
   boolean isValidXGerritAuth(String keyIn);
 
-  ExternalId.Key getLastLoginExternalId();
-
   CurrentUser getUser();
 
   void login(AuthResult res, boolean rememberMe);
diff --git a/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java b/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
index 509a9f1..e20c9b9 100644
--- a/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
+++ b/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
@@ -35,6 +35,7 @@
 import java.io.IOException;
 import java.io.OutputStream;
 import java.util.Locale;
+import java.util.Optional;
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -124,8 +125,8 @@
   }
 
   private static boolean correctUser(String user, WebSession session) {
-    ExternalId.Key id = session.getLastLoginExternalId();
-    return id != null && id.equals(ExternalId.Key.create(SCHEME_GERRIT, user));
+    Optional<ExternalId.Key> id = session.getUser().getLastLoginExternalIdKey();
+    return id.map(i -> i.equals(ExternalId.Key.create(SCHEME_GERRIT, user))).orElse(false);
   }
 
   String getRemoteUser(HttpServletRequest req) {
diff --git a/java/com/google/gerrit/httpd/auth/oauth/BUILD b/java/com/google/gerrit/httpd/auth/oauth/BUILD
index 11c9295..2bc65de4 100644
--- a/java/com/google/gerrit/httpd/auth/oauth/BUILD
+++ b/java/com/google/gerrit/httpd/auth/oauth/BUILD
@@ -7,6 +7,7 @@
     resources = ["//resources/com/google/gerrit/httpd/auth/oauth"],
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/auth",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
diff --git a/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java b/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
index ea0c148..70ed79b 100644
--- a/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
+++ b/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
@@ -20,6 +20,7 @@
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.BaseEncoding;
+import com.google.gerrit.auth.oauth.OAuthTokenCache;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.auth.oauth.OAuthServiceProvider;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
@@ -35,7 +36,6 @@
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.auth.oauth.OAuthTokenCache;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.servlet.SessionScoped;
diff --git a/java/com/google/gerrit/httpd/auth/restapi/BUILD b/java/com/google/gerrit/httpd/auth/restapi/BUILD
new file mode 100644
index 0000000..d499768
--- /dev/null
+++ b/java/com/google/gerrit/httpd/auth/restapi/BUILD
@@ -0,0 +1,14 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+java_library(
+    name = "restapi",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/auth",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/server",
+        "//lib/flogger:api",
+        "//lib/guice",
+    ],
+)
diff --git a/java/com/google/gerrit/server/restapi/account/GetOAuthToken.java b/java/com/google/gerrit/httpd/auth/restapi/GetOAuthToken.java
similarity index 96%
rename from java/com/google/gerrit/server/restapi/account/GetOAuthToken.java
rename to java/com/google/gerrit/httpd/auth/restapi/GetOAuthToken.java
index 24682c0..3594c7c 100644
--- a/java/com/google/gerrit/server/restapi/account/GetOAuthToken.java
+++ b/java/com/google/gerrit/httpd/auth/restapi/GetOAuthToken.java
@@ -12,9 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.restapi.account;
+package com.google.gerrit.httpd.auth.restapi;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.auth.oauth.OAuthTokenCache;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -22,7 +23,6 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountResource;
-import com.google.gerrit.server.auth.oauth.OAuthTokenCache;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
diff --git a/java/com/google/gerrit/httpd/auth/restapi/OAuthRestModule.java b/java/com/google/gerrit/httpd/auth/restapi/OAuthRestModule.java
new file mode 100644
index 0000000..508ad89
--- /dev/null
+++ b/java/com/google/gerrit/httpd/auth/restapi/OAuthRestModule.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.httpd.auth.restapi;
+
+import static com.google.gerrit.server.account.AccountResource.ACCOUNT_KIND;
+
+import com.google.gerrit.extensions.restapi.RestApiModule;
+
+public class OAuthRestModule extends RestApiModule {
+  @Override
+  protected void configure() {
+    get(ACCOUNT_KIND, "oauthtoken").to(GetOAuthToken.class);
+  }
+}
diff --git a/java/com/google/gerrit/httpd/init/BUILD b/java/com/google/gerrit/httpd/init/BUILD
index 222041a..37c63a2 100644
--- a/java/com/google/gerrit/httpd/init/BUILD
+++ b/java/com/google/gerrit/httpd/init/BUILD
@@ -5,12 +5,14 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/auth",
         "//java/com/google/gerrit/elasticsearch",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/gpg",
         "//java/com/google/gerrit/httpd",
         "//java/com/google/gerrit/httpd/auth/oauth",
         "//java/com/google/gerrit/httpd/auth/openid",
+        "//java/com/google/gerrit/httpd/auth/restapi",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/lucene",
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index 193c4f1..2df4739 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.Splitter;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.auth.AuthModule;
 import com.google.gerrit.elasticsearch.ElasticIndexModule;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.gpg.GpgModule;
@@ -36,6 +37,7 @@
 import com.google.gerrit.httpd.WebSshGlueModule;
 import com.google.gerrit.httpd.auth.oauth.OAuthModule;
 import com.google.gerrit.httpd.auth.openid.OpenIdModule;
+import com.google.gerrit.httpd.auth.restapi.OAuthRestModule;
 import com.google.gerrit.httpd.plugins.HttpPluginModule;
 import com.google.gerrit.httpd.raw.StaticModule;
 import com.google.gerrit.index.IndexType;
@@ -322,7 +324,7 @@
     if (VersionManager.getOnlineUpgrade(config)) {
       modules.add(new OnlineUpgrader.Module());
     }
-
+    modules.add(new OAuthRestModule());
     modules.add(new RestApiModule());
     modules.add(new SubscriptionGraph.Module());
     modules.add(new SuperprojectUpdateSubmissionListener.Module());
@@ -408,6 +410,8 @@
     } else if (authConfig.getAuthType() == AuthType.OAUTH) {
       modules.add(new OAuthModule());
     }
+    modules.add(new AuthModule(authConfig));
+
     modules.add(sysInjector.getInstance(GetUserFilter.Module.class));
 
     // StaticModule contains a "/*" wildcard, place it last.
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/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index 46dde41..8d52f5a 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -20,7 +20,6 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
@@ -31,6 +30,7 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.json.OutputFormat;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.gson.Gson;
 import com.google.template.soy.data.SanitizedContent;
 import java.net.URI;
@@ -38,21 +38,15 @@
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
-import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
 import java.util.function.Function;
-import org.eclipse.jgit.lib.Config;
 
 /** Helper for generating parts of {@code index.html}. */
 @UsedAt(Project.GOOGLE)
 public class IndexHtmlUtil {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  static final ImmutableSet<String> DEFAULT_EXPERIMENTS =
-      ImmutableSet.of(
-          "UiFeature__patchset_comments", "UiFeature__patchset_choice_for_comment_links");
-
   private static final Gson GSON = OutputFormat.JSON_COMPACT.newGson();
   /**
    * Returns both static and dynamic parameters of {@code index.html}. The result is to be used when
@@ -60,7 +54,7 @@
    */
   public static ImmutableMap<String, Object> templateData(
       GerritApi gerritApi,
-      Config gerritServerConfig,
+      ExperimentFeatures experimentFeatures,
       String canonicalURL,
       String cdnPath,
       String faviconPath,
@@ -73,14 +67,8 @@
             staticTemplateData(
                 canonicalURL, cdnPath, faviconPath, urlParameterMap, urlInScriptTagOrdainer))
         .putAll(dynamicTemplateData(gerritApi, requestedURL));
+    Set<String> enabledExperiments = experimentFeatures.getEnabledExperimentFeatures();
 
-    Set<String> enabledExperiments = new HashSet<>();
-    Arrays.stream(gerritServerConfig.getStringList("experiments", null, "enabled"))
-        .forEach(enabledExperiments::add);
-    DEFAULT_EXPERIMENTS.forEach(enabledExperiments::add);
-    Arrays.stream(gerritServerConfig.getStringList("experiments", null, "disabled"))
-        .forEach(enabledExperiments::remove);
-    experimentData(urlParameterMap).forEach(enabledExperiments::add);
     if (!enabledExperiments.isEmpty()) {
       data.put("enabledExperiments", serializeObject(GSON, enabledExperiments).toString());
     }
diff --git a/java/com/google/gerrit/httpd/raw/IndexServlet.java b/java/com/google/gerrit/httpd/raw/IndexServlet.java
index b2bdf7c..3f2c202 100644
--- a/java/com/google/gerrit/httpd/raw/IndexServlet.java
+++ b/java/com/google/gerrit/httpd/raw/IndexServlet.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.template.soy.SoyFileSet;
 import com.google.template.soy.data.SanitizedContent;
 import com.google.template.soy.data.UnsafeSanitizedContentOrdainer;
@@ -34,7 +35,6 @@
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
-import org.eclipse.jgit.lib.Config;
 
 public class IndexServlet extends HttpServlet {
   private static final long serialVersionUID = 1L;
@@ -43,7 +43,7 @@
   @Nullable private final String cdnPath;
   @Nullable private final String faviconPath;
   private final GerritApi gerritApi;
-  private final Config gerritServerConfig;
+  private final ExperimentFeatures experimentFeatures;
   private final SoySauce soySauce;
   private final Function<String, SanitizedContent> urlOrdainer;
 
@@ -52,12 +52,12 @@
       @Nullable String cdnPath,
       @Nullable String faviconPath,
       GerritApi gerritApi,
-      Config gerritServerConfig) {
+      ExperimentFeatures experimentFeatures) {
     this.canonicalUrl = canonicalUrl;
     this.cdnPath = cdnPath;
     this.faviconPath = faviconPath;
     this.gerritApi = gerritApi;
-    this.gerritServerConfig = gerritServerConfig;
+    this.experimentFeatures = experimentFeatures;
     this.soySauce =
         SoyFileSet.builder()
             .add(Resources.getResource("com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy"))
@@ -79,7 +79,7 @@
       ImmutableMap<String, Object> templateData =
           IndexHtmlUtil.templateData(
               gerritApi,
-              gerritServerConfig,
+              experimentFeatures,
               canonicalUrl,
               cdnPath,
               faviconPath,
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index 66e107b..cac716f 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.config.GerritOptions;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.inject.Inject;
 import com.google.inject.Key;
 import com.google.inject.Provides;
@@ -221,11 +222,12 @@
     HttpServlet getPolyGerritUiIndexServlet(
         @CanonicalWebUrl @Nullable String canonicalUrl,
         @GerritServerConfig Config cfg,
-        GerritApi gerritApi) {
+        GerritApi gerritApi,
+        ExperimentFeatures experimentFeatures) {
       String cdnPath =
           options.useDevCdn() ? options.devCdn() : cfg.getString("gerrit", null, "cdnPath");
       String faviconPath = cfg.getString("gerrit", null, "faviconPath");
-      return new IndexServlet(canonicalUrl, cdnPath, faviconPath, gerritApi, cfg);
+      return new IndexServlet(canonicalUrl, cdnPath, faviconPath, gerritApi, experimentFeatures);
     }
 
     @Provides
diff --git a/java/com/google/gerrit/httpd/restapi/ParameterParser.java b/java/com/google/gerrit/httpd/restapi/ParameterParser.java
index 172321d..3ab409e 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,7 @@
 import com.google.gson.JsonObject;
 import com.google.gson.JsonPrimitive;
 import com.google.inject.Inject;
-import com.google.inject.Injector;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.io.StringWriter;
 import java.util.HashSet;
@@ -149,24 +148,32 @@
   }
 
   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;
   }
 
+  /**
+   * Parses query parameters ({@code in}) into annotated option fields of {@code param}.
+   *
+   * @return true if parsing was successful. Requesting help is considered failure and returns
+   *     false.
+   */
   <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 {
+    if (param.getClass().getAnnotation(Singleton.class) != null) {
+      // Command-line parsing mutates the object, so we can't have options on @Singleton.
+      return true;
+    }
     CmdLineParser clp = parserFactory.create(param);
-    DynamicOptions pluginOptions = new DynamicOptions(param, injector, dynamicBeans);
+    pluginOptions.setBean(param);
+    pluginOptions.startLifecycleListeners();
     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 ce5de3c..6f3d5c6 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -102,6 +102,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;
@@ -144,6 +145,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;
@@ -248,6 +250,8 @@
     final ChangeFinder changeFinder;
     final RetryHelper retryHelper;
     final PluginSetContext<ExceptionHook> exceptionHooks;
+    final Injector injector;
+    final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
 
     @Inject
     Globals(
@@ -263,7 +267,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;
@@ -278,6 +284,8 @@
       this.retryHelper = retryHelper;
       this.exceptionHooks = exceptionHooks;
       allowOrigin = makeAllowOrigin(config);
+      this.injector = injector;
+      this.dynamicBeans = dynamicBeans;
     }
 
     private static Pattern makeAllowOrigin(Config cfg) {
@@ -496,105 +504,116 @@
             return;
           }
 
-          if (!globals.paramParser.get().parse(viewData.view, qp.params(), req, res)) {
-            return;
+          try (DynamicOptions pluginOptions =
+              new DynamicOptions(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) {
@@ -1631,9 +1650,6 @@
           "Invalid authentication method. In order to authenticate, "
               + "prefix the REST endpoint URL with /a/ (e.g. http://example.com/a/projects/).");
     }
-    if (user.isIdentifiedUser()) {
-      user.setLastLoginExternalIdKey(globals.webSession.get().getLastLoginExternalId());
-    }
   }
 
   private List<String> getParameterNames(HttpServletRequest req) {
diff --git a/java/com/google/gerrit/index/RefState.java b/java/com/google/gerrit/index/RefState.java
index 956dcab..ed38de9 100644
--- a/java/com/google/gerrit/index/RefState.java
+++ b/java/com/google/gerrit/index/RefState.java
@@ -20,8 +20,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Splitter;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.SetMultimap;
+import com.google.common.collect.ImmutableSetMultimap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.git.ObjectIds;
@@ -33,10 +32,10 @@
 
 @AutoValue
 public abstract class RefState {
-  public static SetMultimap<Project.NameKey, RefState> parseStates(Iterable<byte[]> states) {
+  public static ImmutableSetMultimap<Project.NameKey, RefState> parseStates(
+      Iterable<byte[]> states) {
     RefState.check(states != null, null);
-    SetMultimap<Project.NameKey, RefState> result =
-        MultimapBuilder.hashKeys().hashSetValues().build();
+    ImmutableSetMultimap.Builder<Project.NameKey, RefState> result = ImmutableSetMultimap.builder();
     for (byte[] b : states) {
       RefState.check(b != null, null);
       String s = new String(b, UTF_8);
@@ -44,7 +43,7 @@
       RefState.check(parts.size() == 3 && !parts.get(0).isEmpty() && !parts.get(1).isEmpty(), s);
       result.put(Project.nameKey(parts.get(0)), RefState.create(parts.get(1), parts.get(2)));
     }
-    return result;
+    return result.build();
   }
 
   public static RefState create(String ref, String sha) {
diff --git a/java/com/google/gerrit/index/query/TimestampRangePredicate.java b/java/com/google/gerrit/index/query/TimestampRangePredicate.java
index 38b2b73..42f8aa8 100644
--- a/java/com/google/gerrit/index/query/TimestampRangePredicate.java
+++ b/java/com/google/gerrit/index/query/TimestampRangePredicate.java
@@ -34,6 +34,10 @@
     super(def, name, value);
   }
 
+  protected Timestamp getValueTimestamp(I object) {
+    return (Timestamp) this.getField().get(object);
+  }
+
   public abstract Date getMinTimestamp();
 
   public abstract Date getMaxTimestamp();
diff --git a/java/com/google/gerrit/lucene/ChangeSubIndex.java b/java/com/google/gerrit/lucene/ChangeSubIndex.java
index e51a91a7..43daf25 100644
--- a/java/com/google/gerrit/lucene/ChangeSubIndex.java
+++ b/java/com/google/gerrit/lucene/ChangeSubIndex.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.gerrit.lucene.LuceneChangeIndex.ID2_SORT_FIELD;
 import static com.google.gerrit.lucene.LuceneChangeIndex.ID_SORT_FIELD;
+import static com.google.gerrit.lucene.LuceneChangeIndex.MERGED_ON_SORT_FIELD;
 import static com.google.gerrit.lucene.LuceneChangeIndex.UPDATED_SORT_FIELD;
 import static com.google.gerrit.server.index.change.ChangeSchemaDefinitions.NAME;
 
@@ -110,6 +111,9 @@
     } else if (f == ChangeField.UPDATED) {
       long t = ((Timestamp) getOnlyElement(values.getValues())).getTime();
       doc.add(new NumericDocValuesField(UPDATED_SORT_FIELD, t));
+    } else if (f == ChangeField.MERGED_ON) {
+      long t = ((Timestamp) getOnlyElement(values.getValues())).getTime();
+      doc.add(new NumericDocValuesField(MERGED_ON_SORT_FIELD, t));
     }
     super.add(doc, values);
   }
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index bf1a166..c3d4440 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -49,6 +49,7 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.Predicate;
@@ -72,6 +73,7 @@
 import com.google.protobuf.MessageLite;
 import java.io.IOException;
 import java.nio.file.Path;
+import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Iterator;
@@ -110,6 +112,7 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   static final String UPDATED_SORT_FIELD = sortFieldName(ChangeField.UPDATED);
+  static final String MERGED_ON_SORT_FIELD = sortFieldName(ChangeField.MERGED_ON);
   static final String ID_SORT_FIELD = sortFieldName(ChangeField.LEGACY_ID);
   static final String ID2_SORT_FIELD = sortFieldName(ChangeField.LEGACY_ID_STR);
 
@@ -140,6 +143,7 @@
   private static final String UNRESOLVED_COMMENT_COUNT_FIELD =
       ChangeField.UNRESOLVED_COMMENT_COUNT.getName();
   private static final String ATTENTION_SET_FULL_FIELD = ChangeField.ATTENTION_SET_FULL.getName();
+  private static final String MERGED_ON_FIELD = ChangeField.MERGED_ON.getName();
 
   @FunctionalInterface
   static interface IdTerm {
@@ -320,6 +324,7 @@
   private Sort getSort() {
     return new Sort(
         new SortField(UPDATED_SORT_FIELD, SortField.Type.LONG, true),
+        new SortField(MERGED_ON_SORT_FIELD, SortField.Type.LONG, true),
         new SortField(idSortFieldName, SortField.Type.LONG, true));
   }
 
@@ -563,6 +568,9 @@
     if (fields.contains(REF_STATE_PATTERN_FIELD)) {
       decodeRefStatePatterns(doc, cd);
     }
+    if (fields.contains(MERGED_ON_FIELD)) {
+      decodeMergedOn(doc, cd);
+    }
 
     decodeUnresolvedCommentCount(doc, cd);
     decodeTotalCommentCount(doc, cd);
@@ -695,7 +703,7 @@
   }
 
   private void decodeRefStates(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setRefStates(copyAsBytes(doc.get(REF_STATE_FIELD)));
+    cd.setRefStates(RefState.parseStates(copyAsBytes(doc.get(REF_STATE_FIELD))));
   }
 
   private void decodeRefStatePatterns(ListMultimap<String, IndexableField> doc, ChangeData cd) {
@@ -719,6 +727,16 @@
     }
   }
 
+  private void decodeMergedOn(ListMultimap<String, IndexableField> doc, ChangeData cd) {
+    IndexableField mergedOnField =
+        Iterables.getFirst(doc.get(MERGED_ON_FIELD), /* defaultValue= */ null);
+    Timestamp mergedOn = null;
+    if (mergedOnField != null && mergedOnField.numericValue() != null) {
+      mergedOn = new Timestamp(mergedOnField.numericValue().longValue());
+    }
+    cd.setMergedOn(mergedOn);
+  }
+
   private static <T> List<T> decodeProtos(
       ListMultimap<String, IndexableField> doc, String fieldName, ProtoConverter<?, T> converter) {
     return doc.get(fieldName).stream()
diff --git a/java/com/google/gerrit/pgm/BUILD b/java/com/google/gerrit/pgm/BUILD
index a57b37a..faedcb7 100644
--- a/java/com/google/gerrit/pgm/BUILD
+++ b/java/com/google/gerrit/pgm/BUILD
@@ -7,6 +7,7 @@
     resources = ["//resources/com/google/gerrit/pgm"],
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/auth",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/elasticsearch",
@@ -17,6 +18,7 @@
         "//java/com/google/gerrit/httpd",
         "//java/com/google/gerrit/httpd/auth/oauth",
         "//java/com/google/gerrit/httpd/auth/openid",
+        "//java/com/google/gerrit/httpd/auth/restapi",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index/project",
         "//java/com/google/gerrit/launcher",
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index f21a350..16c9d27 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -21,6 +21,7 @@
 import com.google.common.base.Joiner;
 import com.google.common.base.MoreObjects;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.auth.AuthModule;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.elasticsearch.ElasticIndexModule;
 import com.google.gerrit.extensions.client.AuthType;
@@ -40,6 +41,7 @@
 import com.google.gerrit.httpd.WebSshGlueModule;
 import com.google.gerrit.httpd.auth.oauth.OAuthModule;
 import com.google.gerrit.httpd.auth.openid.OpenIdModule;
+import com.google.gerrit.httpd.auth.restapi.OAuthRestModule;
 import com.google.gerrit.httpd.plugins.HttpPluginModule;
 import com.google.gerrit.httpd.raw.StaticModule;
 import com.google.gerrit.index.IndexType;
@@ -456,6 +458,7 @@
     if (VersionManager.getOnlineUpgrade(config)) {
       modules.add(new OnlineUpgrader.Module());
     }
+    modules.add(new OAuthRestModule());
     modules.add(new RestApiModule());
     modules.add(new GpgModule(config));
     modules.add(new StartupChecks.Module());
@@ -510,6 +513,9 @@
     List<Module> libModules = LibModuleLoader.loadModules(cfgInjector, LibModuleType.SYS_MODULE);
     libModules.addAll(testSysModules);
 
+    AuthConfig authConfig = cfgInjector.getInstance(AuthConfig.class);
+    modules.add(new AuthModule(authConfig));
+
     return cfgInjector.createChildInjector(ModuleOverloader.override(modules, libModules));
   }
 
@@ -594,6 +600,7 @@
     } else if (authConfig.getAuthType() == AuthType.OAUTH) {
       modules.add(new OAuthModule());
     }
+
     modules.add(sysInjector.getInstance(GetUserFilter.Module.class));
 
     // StaticModule contains a "/*" wildcard, place it last.
diff --git a/java/com/google/gerrit/pgm/init/InitJGitConfig.java b/java/com/google/gerrit/pgm/init/InitJGitConfig.java
index 6e37f7f..bad55b4 100644
--- a/java/com/google/gerrit/pgm/init/InitJGitConfig.java
+++ b/java/com/google/gerrit/pgm/init/InitJGitConfig.java
@@ -64,22 +64,9 @@
                 + "gc should be configured in gc config section or run as a separate process.");
       }
 
-      if (!jgitConfig
+      if (jgitConfig
           .getNames(ConfigConstants.CONFIG_PROTOCOL_SECTION)
           .contains(ConfigConstants.CONFIG_KEY_VERSION)) {
-        jgitConfig.setString(
-            ConfigConstants.CONFIG_PROTOCOL_SECTION,
-            null,
-            ConfigConstants.CONFIG_KEY_VERSION,
-            TransferConfig.ProtocolVersion.V2.version());
-        jgitConfig.save();
-        ui.error(
-            String.format(
-                "Auto-configured \"%s.%s = %s\" to activate git wire protocol version 2.",
-                ConfigConstants.CONFIG_PROTOCOL_SECTION,
-                ConfigConstants.CONFIG_KEY_VERSION,
-                TransferConfig.ProtocolVersion.V2.version()));
-      } else {
         String version =
             jgitConfig.getString(
                 ConfigConstants.CONFIG_PROTOCOL_SECTION, null, ConfigConstants.CONFIG_KEY_VERSION);
diff --git a/java/com/google/gerrit/pgm/util/BUILD b/java/com/google/gerrit/pgm/util/BUILD
index f7c2b75..cfdd383 100644
--- a/java/com/google/gerrit/pgm/util/BUILD
+++ b/java/com/google/gerrit/pgm/util/BUILD
@@ -12,7 +12,6 @@
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/metrics/dropwizard",
         "//java/com/google/gerrit/server",
-        "//java/com/google/gerrit/server/cache/h2",
         "//java/com/google/gerrit/server/cache/mem",
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/schema",
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index e9c0136..894757b 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -39,7 +39,6 @@
 import com.google.gerrit.server.account.externalids.ExternalIdModule;
 import com.google.gerrit.server.cache.CacheRemovalListener;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
-import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
@@ -116,8 +115,6 @@
     bind(new TypeLiteral<List<CommentLinkInfo>>() {})
         .toProvider(CommentLinkProvider.class)
         .in(SINGLETON);
-    bind(new TypeLiteral<DynamicSet<ChangeAttributeFactory>>() {})
-        .toInstance(DynamicSet.emptySet());
     bind(new TypeLiteral<DynamicMap<RestView<CommitResource>>>() {})
         .toInstance(DynamicMap.emptyMap());
     bind(String.class)
diff --git a/java/com/google/gerrit/pgm/util/SiteProgram.java b/java/com/google/gerrit/pgm/util/SiteProgram.java
index 98558fb..c3be0a4 100644
--- a/java/com/google/gerrit/pgm/util/SiteProgram.java
+++ b/java/com/google/gerrit/pgm/util/SiteProgram.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.config.GerritRuntime;
 import com.google.gerrit.server.config.GerritServerConfigModule;
 import com.google.gerrit.server.config.SitePath;
+import com.google.gerrit.server.experiments.ConfigExperimentFeatures;
 import com.google.gerrit.server.git.GitRepositoryManagerModule;
 import com.google.gerrit.server.git.SystemReaderInstaller;
 import com.google.gerrit.server.schema.SchemaModule;
@@ -128,6 +129,9 @@
 
     modules.add(new SchemaModule());
     modules.add(cfgInjector.getInstance(GitRepositoryManagerModule.class));
+    // The only implementation of experiments is available in all programs that can use
+    // gerrit.config
+    modules.add(new ConfigExperimentFeatures.Module());
 
     try {
       return Guice.createInjector(
diff --git a/java/com/google/gerrit/server/ApprovalInference.java b/java/com/google/gerrit/server/ApprovalInference.java
index aa3ef89..d77427a 100644
--- a/java/com/google/gerrit/server/ApprovalInference.java
+++ b/java/com/google/gerrit/server/ApprovalInference.java
@@ -25,15 +25,23 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.server.change.ChangeKindCache;
 import com.google.gerrit.server.change.LabelNormalizer;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
@@ -59,13 +67,18 @@
   private final ProjectCache projectCache;
   private final ChangeKindCache changeKindCache;
   private final LabelNormalizer labelNormalizer;
+  private final PatchListCache patchListCache;
 
   @Inject
   ApprovalInference(
-      ProjectCache projectCache, ChangeKindCache changeKindCache, LabelNormalizer labelNormalizer) {
+      ProjectCache projectCache,
+      ChangeKindCache changeKindCache,
+      LabelNormalizer labelNormalizer,
+      PatchListCache patchListCache) {
     this.projectCache = projectCache;
     this.changeKindCache = changeKindCache;
     this.labelNormalizer = labelNormalizer;
+    this.patchListCache = patchListCache;
   }
 
   /**
@@ -93,10 +106,15 @@
   }
 
   private static boolean canCopy(
-      ProjectState project, PatchSetApproval psa, PatchSet.Id psId, ChangeKind kind) {
+      ProjectState project,
+      PatchSetApproval psa,
+      PatchSet.Id psId,
+      ChangeKind kind,
+      LabelType type,
+      @Nullable PatchList patchList) {
     int n = psa.key().patchSetId().get();
     checkArgument(n != psId.get());
-    LabelType type = project.getLabelTypes().byLabel(psa.labelId());
+
     if (type == null) {
       logger.atFine().log(
           "approval %d on label %s of patch set %d of change %d cannot be copied"
@@ -153,6 +171,25 @@
           psa.value(),
           project.getName());
       return true;
+    } else if (type.isCopyAllScoresIfListOfFilesDidNotChange()
+        && patchList.getPatches().stream()
+            .noneMatch(
+                p ->
+                    p.getChangeType() == ChangeType.ADDED
+                        || p.getChangeType() == ChangeType.DELETED)) {
+      logger.atFine().log(
+          "approval %d on label %s of patch set %d of change %d can be copied"
+              + " to patch set %d because the label has set "
+              + "copyAllScoresIfListOfFilesDidNotChange = true on "
+              + "project %s and list of files did not change (maybe except a rename, which is "
+              + "still the same file).",
+          psa.value(),
+          psa.label(),
+          n,
+          psa.key().patchSetId().changeId().get(),
+          psId.get(),
+          project.getName());
+      return true;
     }
     switch (kind) {
       case MERGE_FIRST_PARENT_UPDATE:
@@ -331,15 +368,44 @@
     logger.atFine().log(
         "change kind for patch set %d of change %d against prior patch set %s is %s",
         ps.id().get(), ps.id().changeId().get(), priorPatchSet.getValue().id().changeId(), kind);
+    PatchList patchList = null;
+    LabelTypes labelTypes = project.getLabelTypes();
     for (PatchSetApproval psa : priorApprovals) {
       if (resultByUser.contains(psa.label(), psa.accountId())) {
         continue;
       }
-      if (!canCopy(project, psa, ps.id(), kind)) {
+      LabelType type = labelTypes.byLabel(psa.labelId());
+      // Only compute patchList if there is a relevant label, since this is expensive.
+      if (patchList == null && type != null && type.isCopyAllScoresIfListOfFilesDidNotChange()) {
+        patchList = getPatchList(project, ps, priorPatchSet);
+      }
+      if (!canCopy(project, psa, ps.id(), kind, type, patchList)) {
         continue;
       }
       resultByUser.put(psa.label(), psa.accountId(), psa.copyWithPatchSet(ps.id()));
     }
     return resultByUser.values();
   }
+
+  /**
+   * Gets the {@link PatchList} between the two latest patch-sets. Can be used to compute difference
+   * in files between those two patch-sets .
+   */
+  private PatchList getPatchList(
+      ProjectState project, PatchSet ps, Map.Entry<PatchSet.Id, PatchSet> priorPatchSet) {
+    PatchListKey key =
+        PatchListKey.againstCommit(
+            priorPatchSet.getValue().commitId(),
+            ps.commitId(),
+            DiffPreferencesInfo.Whitespace.IGNORE_NONE);
+    try {
+      return patchListCache.get(key, project.getNameKey());
+    } catch (PatchListNotAvailableException ex) {
+      throw new StorageException(
+          "failed to compute difference in files, so won't copy"
+              + " votes on labels even if list of files is the same and "
+              + "copyAllIfListOfFilesDidNotChange",
+          ex);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 069006b..9fa7456 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -62,7 +62,6 @@
         "//java/com/google/gerrit/server/util/git",
         "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/util/cli",
-        "//java/com/google/gerrit/util/ssl",
         "//java/org/apache/commons/net",
         "//lib:args4j",
         "//lib:autolink",
@@ -113,6 +112,7 @@
         "//lib/commons:compress",
         "//lib/commons:dbcp",
         "//lib/commons:lang",
+        "//lib/commons:lang3",
         "//lib/commons:net",
         "//lib/commons:validator",
         "//lib/errorprone:annotations",
diff --git a/java/com/google/gerrit/server/CommentContextLoader.java b/java/com/google/gerrit/server/CommentContextLoader.java
deleted file mode 100644
index bbc7cf3..0000000
--- a/java/com/google/gerrit/server/CommentContextLoader.java
+++ /dev/null
@@ -1,165 +0,0 @@
-// Copyright (C) 2020 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server;
-
-import static java.util.stream.Collectors.groupingBy;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.common.ContextLineInfo;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.patch.Text;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.treewalk.TreeWalk;
-
-/**
- * Computes the list of {@link ContextLineInfo} for a given comment, that is, the lines of the
- * source file surrounding and including the area where the comment was written.
- */
-public class CommentContextLoader {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final GitRepositoryManager repoManager;
-  private final Project.NameKey project;
-  private Map<ContextData, List<ContextLineInfo>> candidates;
-
-  public interface Factory {
-    CommentContextLoader create(Project.NameKey project);
-  }
-
-  @Inject
-  CommentContextLoader(GitRepositoryManager repoManager, @Assisted Project.NameKey project) {
-    this.repoManager = repoManager;
-    this.project = project;
-    this.candidates = new HashMap<>();
-  }
-
-  /**
-   * Returns an empty list of {@link ContextLineInfo}. Clients are expected to call this method one
-   * or more times. Each call returns a reference to an empty {@link List
-   * List&lt;ContextLineInfo&gt;}.
-   *
-   * <p>A single call to {@link #fill()} will cause all list references returned from this method to
-   * be populated. If a client calls this method again with a comment that was passed before calling
-   * {@link #fill()}, the new populated list will be returned.
-   *
-   * @param comment the comment entity for which we want to load the context
-   * @return a list of {@link ContextLineInfo}
-   */
-  public List<ContextLineInfo> getContext(CommentInfo comment) {
-    ContextData key =
-        ContextData.create(
-            comment.id,
-            ObjectId.fromString(comment.commitId),
-            comment.path,
-            getStartAndEndLines(comment));
-    List<ContextLineInfo> context = candidates.get(key);
-    if (context == null) {
-      context = new ArrayList<>();
-      candidates.put(key, context);
-    }
-    return context;
-  }
-
-  /**
-   * A call to this method loads the context for all comments stored in {@link
-   * CommentContextLoader#candidates}. This is useful so that the repository is opened once for all
-   * comments.
-   */
-  public void fill() {
-    // Group comments by commit ID so that each commit is parsed only once
-    Map<ObjectId, List<ContextData>> commentsByCommitId =
-        candidates.keySet().stream().collect(groupingBy(ContextData::commitId));
-
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk rw = new RevWalk(repo)) {
-      for (ObjectId commitId : commentsByCommitId.keySet()) {
-        RevCommit commit = rw.parseCommit(commitId);
-        for (ContextData k : commentsByCommitId.get(commitId)) {
-          if (!k.range().isPresent()) {
-            continue;
-          }
-          try (TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), k.path(), commit.getTree())) {
-            if (tw == null) {
-              logger.atWarning().log(
-                  "Failed to find path %s in the git tree of ID %s.",
-                  k.path(), commit.getTree().getId());
-              continue;
-            }
-            ObjectId id = tw.getObjectId(0);
-            Text src = new Text(repo.open(id, Constants.OBJ_BLOB));
-            List<ContextLineInfo> contextLines = candidates.get(k);
-            Range r = k.range().get();
-            for (int i = r.start(); i <= r.end(); i++) {
-              contextLines.add(new ContextLineInfo(i, src.getString(i - 1)));
-            }
-          }
-        }
-      }
-    } catch (IOException e) {
-      throw new StorageException("Failed to load the comment context", e);
-    }
-  }
-
-  private static Optional<Range> getStartAndEndLines(CommentInfo comment) {
-    if (comment.range != null) {
-      return Optional.of(Range.create(comment.range.startLine, comment.range.endLine));
-    } else if (comment.line != null) {
-      return Optional.of(Range.create(comment.line, comment.line));
-    }
-    return Optional.empty();
-  }
-
-  @AutoValue
-  abstract static class Range {
-    static Range create(int start, int end) {
-      return new AutoValue_CommentContextLoader_Range(start, end);
-    }
-
-    abstract int start();
-
-    abstract int end();
-  }
-
-  @AutoValue
-  abstract static class ContextData {
-    static ContextData create(String id, ObjectId commitId, String path, Optional<Range> range) {
-      return new AutoValue_CommentContextLoader_ContextData(id, commitId, path, range);
-    }
-
-    abstract String id();
-
-    abstract ObjectId commitId();
-
-    abstract String path();
-
-    abstract Optional<Range> range();
-  }
-}
diff --git a/java/com/google/gerrit/server/CurrentUser.java b/java/com/google/gerrit/server/CurrentUser.java
index 75afc04..7012944 100644
--- a/java/com/google/gerrit/server/CurrentUser.java
+++ b/java/com/google/gerrit/server/CurrentUser.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server;
 
-import com.google.gerrit.common.Nullable;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.externalids.ExternalId;
@@ -31,17 +31,19 @@
  * @see IdentifiedUser
  */
 public abstract class CurrentUser {
-  /** Unique key for plugin/extension specific data on a CurrentUser. */
-  public static final class PropertyKey<T> {
-    public static <T> PropertyKey<T> create() {
-      return new PropertyKey<>();
-    }
+  public static final PropertyMap.Key<ExternalId.Key> LAST_LOGIN_EXTERNAL_ID_PROPERTY_KEY =
+      PropertyMap.key();
 
-    private PropertyKey() {}
+  private final PropertyMap properties;
+  private AccessPath accessPath = AccessPath.UNKNOWN;
+
+  protected CurrentUser() {
+    this.properties = PropertyMap.EMPTY;
   }
 
-  private AccessPath accessPath = AccessPath.UNKNOWN;
-  private PropertyKey<ExternalId.Key> lastLoginExternalIdPropertyKey = PropertyKey.create();
+  protected CurrentUser(PropertyMap properties) {
+    this.properties = properties;
+  }
 
   /** How this user is accessing the Gerrit Code Review application. */
   public final AccessPath getAccessPath() {
@@ -127,35 +129,41 @@
         getClass().getSimpleName() + " is not an IdentifiedUser");
   }
 
+  /**
+   * Returns all email addresses associated with this user. For {@link AnonymousUser} and other
+   * users that don't represent a person user or service account, this set will be empty.
+   */
+  public ImmutableSet<String> getEmailAddresses() {
+    return ImmutableSet.of();
+  }
+
+  /**
+   * Returns all {@link com.google.gerrit.server.account.externalids.ExternalId.Key}s associated
+   * with this user. For {@link AnonymousUser} and other users that don't represent a person user or
+   * service account, this set will be empty.
+   */
+  public ImmutableSet<ExternalId.Key> getExternalIdKeys() {
+    return ImmutableSet.of();
+  }
+
   /** Check if the CurrentUser is an InternalUser. */
   public boolean isInternalUser() {
     return false;
   }
 
   /**
-   * Lookup a previously stored property.
+   * Lookup a stored property.
    *
-   * @param key unique property key.
-   * @return previously stored value, or {@code Optional#empty()}.
+   * @param key unique property key. This key has to be the same instance that was used to store the
+   *     value when constructing the {@link PropertyMap}
+   * @return stored value, or {@code Optional#empty()}.
    */
-  public <T> Optional<T> get(PropertyKey<T> key) {
-    return Optional.empty();
-  }
-
-  /**
-   * Store a property for later retrieval.
-   *
-   * @param key unique property key.
-   * @param value value to store; or {@code null} to clear the value.
-   */
-  public <T> void put(PropertyKey<T> key, @Nullable T value) {}
-
-  public void setLastLoginExternalIdKey(ExternalId.Key externalIdKey) {
-    put(lastLoginExternalIdPropertyKey, externalIdKey);
+  public <T> Optional<T> get(PropertyMap.Key<T> key) {
+    return properties.get(key);
   }
 
   public Optional<ExternalId.Key> getLastLoginExternalIdKey() {
-    return get(lastLoginExternalIdPropertyKey);
+    return get(LAST_LOGIN_EXTERNAL_ID_PROPERTY_KEY);
   }
 
   /**
diff --git a/java/com/google/gerrit/server/DynamicOptions.java b/java/com/google/gerrit/server/DynamicOptions.java
index 41dc082..db0aa70 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,13 +193,17 @@
   protected Object bean;
   protected Map<String, DynamicBean> beansByPlugin;
   protected Injector injector;
+  protected DynamicMap<DynamicBean> dynamicBeans;
+  protected LifecycleManager lifecycleManager;
 
   /**
    * Internal: For Gerrit to include options from DynamicBeans, setup a DynamicMap and instantiate
    * this class so the following methods can be called if desired:
    *
    * <pre>
-   *    DynamicOptions pluginOptions = new DynamicOptions(bean, injector, dynamicBeans);
+   *    DynamicOptions pluginOptions = new DynamicOptions(injector, dynamicBeans);
+   *    pluginOptions.setBean(bean);
+   *    pluginOptions.startLifecycleListeners();
    *    pluginOptions.parseDynamicBeans(clp);
    *    pluginOptions.setDynamicBeans();
    *    pluginOptions.onBeanParseStart();
@@ -206,10 +213,15 @@
    *    pluginOptions.onBeanParseEnd();
    * </pre>
    */
-  public DynamicOptions(Object bean, Injector injector, DynamicMap<DynamicBean> dynamicBeans) {
-    this.bean = bean;
+  public DynamicOptions(Injector injector, DynamicMap<DynamicBean> dynamicBeans) {
     this.injector = injector;
+    this.dynamicBeans = dynamicBeans;
+    lifecycleManager = new LifecycleManager();
     beansByPlugin = new HashMap<>();
+  }
+
+  public void setBean(Object bean) {
+    this.bean = bean;
     Class<?> beanClass =
         (bean instanceof BeanReceiver)
             ? ((BeanReceiver) bean).getExportedBeanReceiver()
@@ -255,9 +267,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 +313,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 +340,9 @@
       }
     }
   }
+
+  @Override
+  public void close() {
+    stopLifecycleListeners();
+  }
 }
diff --git a/java/com/google/gerrit/server/ExceptionHookImpl.java b/java/com/google/gerrit/server/ExceptionHookImpl.java
index 8884991..3986842 100644
--- a/java/com/google/gerrit/server/ExceptionHookImpl.java
+++ b/java/com/google/gerrit/server/ExceptionHookImpl.java
@@ -18,6 +18,7 @@
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.exceptions.InternalServerWithUserMessageException;
 import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.server.project.ProjectConfig;
 import java.util.Optional;
@@ -73,6 +74,9 @@
               + "\n"
               + CONTACT_PROJECT_OWNER_USER_MESSAGE);
     }
+    if (throwable instanceof InternalServerWithUserMessageException) {
+      return ImmutableList.of(throwable.getMessage());
+    }
     return ImmutableList.of();
   }
 
diff --git a/java/com/google/gerrit/server/ExternalUser.java b/java/com/google/gerrit/server/ExternalUser.java
new file mode 100644
index 0000000..9680f3e
--- /dev/null
+++ b/java/com/google/gerrit/server/ExternalUser.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import static com.google.common.flogger.LazyArgs.lazy;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.account.GroupMembership;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Collection;
+
+/**
+ * Represents a user that does not have a Gerrit account.
+ *
+ * <p>This user is limited in what they can do on Gerrit. For now, we only guarantee that permission
+ * checking - including ref filtering works.
+ *
+ * <p>This class is thread-safe.
+ */
+public class ExternalUser extends CurrentUser {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public interface Factory {
+    ExternalUser create(
+        Collection<String> emailAddresses,
+        Collection<ExternalId.Key> externalIdKeys,
+        PropertyMap propertyMap);
+  }
+
+  private final GroupBackend groupBackend;
+  private final ImmutableSet<String> emailAddresses;
+  private final ImmutableSet<ExternalId.Key> externalIdKeys;
+
+  private GroupMembership effectiveGroups;
+
+  @Inject
+  public ExternalUser(
+      GroupBackend groupBackend,
+      @Assisted Collection<String> emailAddresses,
+      @Assisted Collection<ExternalId.Key> externalIdKeys,
+      @Assisted PropertyMap propertyMap) {
+    super(propertyMap);
+    this.groupBackend = groupBackend;
+    this.emailAddresses = ImmutableSet.copyOf(emailAddresses);
+    this.externalIdKeys = ImmutableSet.copyOf(externalIdKeys);
+  }
+
+  @Override
+  public ImmutableSet<String> getEmailAddresses() {
+    return emailAddresses;
+  }
+
+  @Override
+  public ImmutableSet<ExternalId.Key> getExternalIdKeys() {
+    return externalIdKeys;
+  }
+
+  @Override
+  public GroupMembership getEffectiveGroups() {
+    synchronized (this) {
+      if (effectiveGroups == null) {
+        effectiveGroups = groupBackend.membershipsOf(this);
+        logger.atFinest().log(
+            "Known groups of %s: %s", getLoggableName(), lazy(effectiveGroups::getKnownGroups));
+      }
+    }
+    return effectiveGroups;
+  }
+
+  @Override
+  public Object getCacheKey() {
+    return this; // Caching is tied to this exact instance.
+  }
+}
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index 7cafdc0..34f0eb5 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -15,13 +15,16 @@
 package com.google.gerrit.server;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.flogger.LazyArgs.lazy;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
@@ -29,6 +32,7 @@
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.ListGroupMembership;
 import com.google.gerrit.server.account.Realm;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.CanonicalWebUrl;
@@ -46,8 +50,6 @@
 import java.net.SocketAddress;
 import java.net.URL;
 import java.util.Date;
-import java.util.HashMap;
-import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
 import java.util.TimeZone;
@@ -105,12 +107,26 @@
       return create(null, id);
     }
 
+    @VisibleForTesting
+    @UsedAt(UsedAt.Project.GOOGLE)
+    public IdentifiedUser forTest(Account.Id id, PropertyMap properties) {
+      return runAs(null, id, null, properties);
+    }
+
     public IdentifiedUser create(SocketAddress remotePeer, Account.Id id) {
       return runAs(remotePeer, id, null);
     }
 
     public IdentifiedUser runAs(
         SocketAddress remotePeer, Account.Id id, @Nullable CurrentUser caller) {
+      return runAs(remotePeer, id, caller, PropertyMap.EMPTY);
+    }
+
+    private IdentifiedUser runAs(
+        SocketAddress remotePeer,
+        Account.Id id,
+        @Nullable CurrentUser caller,
+        PropertyMap properties) {
       return new IdentifiedUser(
           authConfig,
           realm,
@@ -121,7 +137,8 @@
           enableReverseDnsLookup,
           Providers.of(remotePeer),
           id,
-          caller);
+          caller,
+          properties);
     }
   }
 
@@ -163,20 +180,10 @@
     }
 
     public IdentifiedUser create(Account.Id id) {
-      return new IdentifiedUser(
-          authConfig,
-          realm,
-          anonymousCowardName,
-          canonicalUrl,
-          accountCache,
-          groupBackend,
-          enableReverseDnsLookup,
-          remotePeerProvider,
-          id,
-          null);
+      return create(id, PropertyMap.EMPTY);
     }
 
-    public IdentifiedUser runAs(Account.Id id, CurrentUser caller) {
+    public <T> IdentifiedUser create(Account.Id id, PropertyMap properties) {
       return new IdentifiedUser(
           authConfig,
           realm,
@@ -187,7 +194,23 @@
           enableReverseDnsLookup,
           remotePeerProvider,
           id,
-          caller);
+          null,
+          properties);
+    }
+
+    public IdentifiedUser runAs(Account.Id id, CurrentUser caller, PropertyMap properties) {
+      return new IdentifiedUser(
+          authConfig,
+          realm,
+          anonymousCowardName,
+          canonicalUrl,
+          accountCache,
+          groupBackend,
+          enableReverseDnsLookup,
+          remotePeerProvider,
+          id,
+          caller,
+          properties);
     }
   }
 
@@ -212,7 +235,6 @@
   private boolean loadedAllEmails;
   private Set<String> invalidEmails;
   private GroupMembership effectiveGroups;
-  private Map<PropertyKey<Object>, Object> properties;
 
   private IdentifiedUser(
       AuthConfig authConfig,
@@ -235,7 +257,8 @@
         enableReverseDnsLookup,
         remotePeerProvider,
         state.account().id(),
-        realUser);
+        realUser,
+        PropertyMap.EMPTY);
     this.state = state;
   }
 
@@ -249,7 +272,9 @@
       Boolean enableReverseDnsLookup,
       @Nullable Provider<SocketAddress> remotePeerProvider,
       Account.Id id,
-      @Nullable CurrentUser realUser) {
+      @Nullable CurrentUser realUser,
+      PropertyMap properties) {
+    super(properties);
     this.canonicalUrl = canonicalUrl;
     this.accountCache = accountCache;
     this.groupBackend = groupBackend;
@@ -357,6 +382,7 @@
     return false;
   }
 
+  @Override
   public ImmutableSet<String> getEmailAddresses() {
     if (!loadedAllEmails) {
       validEmails.addAll(realm.getEmailAddresses(this));
@@ -365,6 +391,11 @@
     return ImmutableSet.copyOf(validEmails);
   }
 
+  @Override
+  public ImmutableSet<ExternalId.Key> getExternalIdKeys() {
+    return state().externalIds().stream().map(ExternalId::key).collect(toImmutableSet());
+  }
+
   public String getName() {
     return getAccount().getName();
   }
@@ -463,40 +494,6 @@
     return true;
   }
 
-  @Override
-  public synchronized <T> Optional<T> get(PropertyKey<T> key) {
-    if (properties != null) {
-      @SuppressWarnings("unchecked")
-      T value = (T) properties.get(key);
-      return Optional.ofNullable(value);
-    }
-    return Optional.empty();
-  }
-
-  /**
-   * Store a property for later retrieval.
-   *
-   * @param key unique property key.
-   * @param value value to store; or {@code null} to clear the value.
-   */
-  @Override
-  public synchronized <T> void put(PropertyKey<T> key, @Nullable T value) {
-    if (properties == null) {
-      if (value == null) {
-        return;
-      }
-      properties = new HashMap<>();
-    }
-
-    @SuppressWarnings("unchecked")
-    PropertyKey<Object> k = (PropertyKey<Object>) key;
-    if (value != null) {
-      properties.put(k, value);
-    } else {
-      properties.remove(k);
-    }
-  }
-
   /**
    * Returns a materialized copy of the user with all dependencies.
    *
diff --git a/java/com/google/gerrit/server/PropertyMap.java b/java/com/google/gerrit/server/PropertyMap.java
new file mode 100644
index 0000000..da3a2495
--- /dev/null
+++ b/java/com/google/gerrit/server/PropertyMap.java
@@ -0,0 +1,84 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import com.google.common.collect.ImmutableMap;
+import java.util.Optional;
+
+/**
+ * Immutable map that holds a collection of random objects allowing for a type-safe retrieval.
+ *
+ * <p>Intended to be used in {@link CurrentUser} when the object is constructed during login and
+ * holds per-request state. This functionality allows plugins/extensions to contribute specific data
+ * to {@link CurrentUser} that is unknown to Gerrit core.
+ */
+public class PropertyMap {
+  /** Empty instance to be referenced once per JVM. */
+  public static final PropertyMap EMPTY = builder().build();
+
+  /**
+   * Typed key for {@link PropertyMap}. This class intentionally does not implement {@link
+   * Object#equals(Object)} and {@link Object#hashCode()} so that the same instance has to be used
+   * to retrieve a stored value.
+   *
+   * <p>We require the exact same key instance because {@link PropertyMap} is implemented in a
+   * type-safe fashion by using Java generics to guarantee the return type. The generic type can't
+   * be recovered at runtime, so there is no way to just use the type's full name as key - we'd have
+   * to pass additional arguments. At the same time, this is in-line with how we'd want callers to
+   * use {@link PropertyMap}: Instantiate a static, per-JVM key that is reused when setting and
+   * getting values.
+   */
+  public static class Key<T> {}
+
+  public static <T> Key<T> key() {
+    return new Key<>();
+  }
+
+  public static class Builder {
+    private ImmutableMap.Builder<Object, Object> mutableMap;
+
+    private Builder() {
+      this.mutableMap = ImmutableMap.builder();
+    }
+
+    /** Adds the provided {@code value} to the {@link PropertyMap} that is being built. */
+    public <T> Builder put(Key<T> key, T value) {
+      mutableMap.put(key, value);
+      return this;
+    }
+
+    /** Builds and returns an immutable {@link PropertyMap}. */
+    public PropertyMap build() {
+      return new PropertyMap(mutableMap.build());
+    }
+  }
+
+  private final ImmutableMap<Object, Object> map;
+
+  private PropertyMap(ImmutableMap<Object, Object> map) {
+    this.map = map;
+  }
+
+  /** Returns a new {@link Builder} instance. */
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  /** Returns the requested value wrapped as {@link Optional}. */
+  @SuppressWarnings("unchecked")
+  public <T> Optional<T> get(Key<T> key) {
+    return Optional.ofNullable((T) map.get(key));
+  }
+}
diff --git a/java/com/google/gerrit/server/WebLinks.java b/java/com/google/gerrit/server/WebLinks.java
index 88b0b21..e66e7f5 100644
--- a/java/com/google/gerrit/server/WebLinks.java
+++ b/java/com/google/gerrit/server/WebLinks.java
@@ -86,19 +86,29 @@
   /**
    * @param project Project name.
    * @param commit SHA1 of commit.
+   * @param commitMessage the commit message of the commit.
+   * @param branchName branch of the commit.
    * @return Links for patch sets.
    */
-  public ImmutableList<WebLinkInfo> getPatchSetLinks(Project.NameKey project, String commit) {
-    return filterLinks(patchSetLinks, webLink -> webLink.getPatchSetWebLink(project.get(), commit));
+  public ImmutableList<WebLinkInfo> getPatchSetLinks(
+      Project.NameKey project, String commit, String commitMessage, String branchName) {
+    return filterLinks(
+        patchSetLinks,
+        webLink -> webLink.getPatchSetWebLink(project.get(), commit, commitMessage, branchName));
   }
 
   /**
    * @param project Project name.
    * @param revision SHA1 of the parent revision.
+   * @param commitMessage the commit message of the parent revision.
+   * @param branchName branch of the revision (and parent revision).
    * @return Links for patch sets.
    */
-  public ImmutableList<WebLinkInfo> getParentLinks(Project.NameKey project, String revision) {
-    return filterLinks(parentLinks, webLink -> webLink.getParentWebLink(project.get(), revision));
+  public ImmutableList<WebLinkInfo> getParentLinks(
+      Project.NameKey project, String revision, String commitMessage, String branchName) {
+    return filterLinks(
+        parentLinks,
+        webLink -> webLink.getParentWebLink(project.get(), revision, commitMessage, branchName));
   }
 
   /**
diff --git a/java/com/google/gerrit/server/account/AccountCacheImpl.java b/java/com/google/gerrit/server/account/AccountCacheImpl.java
index f68a1c7..93e04880 100644
--- a/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -19,6 +19,7 @@
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.RefNames;
@@ -113,20 +114,21 @@
                 ? defaultPreferenceCache.get(ref.getObjectId())
                 : DefaultPreferencesCache.EMPTY;
 
-        ImmutableMap.Builder<Account.Id, AccountState> result = ImmutableMap.builder();
+        Set<CachedAccountDetails.Key> keys =
+            Sets.newLinkedHashSetWithExpectedSize(accountIds.size());
         for (Account.Id id : accountIds) {
           Ref userRef = allUsers.exactRef(RefNames.refsUsers(id));
           if (userRef == null) {
             continue;
           }
-
+          keys.add(CachedAccountDetails.Key.create(id, userRef.getObjectId()));
+        }
+        ImmutableMap.Builder<Account.Id, AccountState> result = ImmutableMap.builder();
+        for (Map.Entry<CachedAccountDetails.Key, CachedAccountDetails> account :
+            accountDetailsCache.getAll(keys).entrySet()) {
           result.put(
-              id,
-              AccountState.forCachedAccount(
-                  accountDetailsCache.get(
-                      CachedAccountDetails.Key.create(id, userRef.getObjectId())),
-                  defaultPreferences,
-                  externalIds));
+              account.getKey().accountId(),
+              AccountState.forCachedAccount(account.getValue(), defaultPreferences, externalIds));
         }
         return result.build();
       }
diff --git a/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java
index 4dfeab5..2665b9a 100644
--- a/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/java/com/google/gerrit/server/account/AccountResolver.java
@@ -495,7 +495,7 @@
   }
 
   /**
-   * Resolves all accounts matching the input string.
+   * Resolves all accounts matching the input string, visible to the current user.
    *
    * <p>The following input formats are recognized:
    *
diff --git a/java/com/google/gerrit/server/account/GroupBackend.java b/java/com/google/gerrit/server/account/GroupBackend.java
index 545da6e..d6360c5 100644
--- a/java/com/google/gerrit/server/account/GroupBackend.java
+++ b/java/com/google/gerrit/server/account/GroupBackend.java
@@ -19,7 +19,7 @@
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.project.ProjectState;
 import java.util.Collection;
 
@@ -42,7 +42,7 @@
   Collection<GroupReference> suggest(String name, @Nullable ProjectState project);
 
   /** @return the group membership checker for the backend. */
-  GroupMembership membershipsOf(IdentifiedUser user);
+  GroupMembership membershipsOf(CurrentUser user);
 
   /** @return {@code true} if the group with the given UUID is visible to all registered users. */
   boolean isVisibleToAll(AccountGroup.UUID uuid);
diff --git a/java/com/google/gerrit/server/account/IncludingGroupMembership.java b/java/com/google/gerrit/server/account/IncludingGroupMembership.java
index 6dc7976..9cb11a6 100644
--- a/java/com/google/gerrit/server/account/IncludingGroupMembership.java
+++ b/java/com/google/gerrit/server/account/IncludingGroupMembership.java
@@ -14,11 +14,12 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 import com.google.gerrit.entities.AccountGroup;
-import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -40,18 +41,18 @@
  */
 public class IncludingGroupMembership implements GroupMembership {
   public interface Factory {
-    IncludingGroupMembership create(IdentifiedUser user);
+    IncludingGroupMembership create(CurrentUser user);
   }
 
   private final GroupCache groupCache;
   private final GroupIncludeCache includeCache;
-  private final IdentifiedUser user;
+  private final CurrentUser user;
   private final Map<AccountGroup.UUID, Boolean> memberOf;
   private Set<AccountGroup.UUID> knownGroups;
 
   @Inject
   IncludingGroupMembership(
-      GroupCache groupCache, GroupIncludeCache includeCache, @Assisted IdentifiedUser user) {
+      GroupCache groupCache, GroupIncludeCache includeCache, @Assisted CurrentUser user) {
     this.groupCache = groupCache;
     this.includeCache = includeCache;
     this.user = user;
@@ -93,7 +94,7 @@
         if (!group.isPresent()) {
           continue;
         }
-        if (group.get().getMembers().contains(user.getAccountId())) {
+        if (user.isIdentifiedUser() && group.get().getMembers().contains(user.getAccountId())) {
           memberOf.put(id, true);
           return true;
         }
@@ -124,7 +125,10 @@
 
   private ImmutableSet<AccountGroup.UUID> computeKnownGroups() {
     GroupMembership membership = user.getEffectiveGroups();
-    Collection<AccountGroup.UUID> direct = includeCache.getGroupsWithMember(user.getAccountId());
+    Collection<AccountGroup.UUID> direct =
+        user.isIdentifiedUser()
+            ? includeCache.getGroupsWithMember(user.getAccountId())
+            : ImmutableList.of();
     direct.forEach(groupUuid -> memberOf.put(groupUuid, true));
     Set<AccountGroup.UUID> r = Sets.newHashSet(direct);
     r.remove(null);
diff --git a/java/com/google/gerrit/server/account/InternalGroupBackend.java b/java/com/google/gerrit/server/account/InternalGroupBackend.java
index c520c96..8761081 100644
--- a/java/com/google/gerrit/server/account/InternalGroupBackend.java
+++ b/java/com/google/gerrit/server/account/InternalGroupBackend.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
-import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.InternalGroupDescription;
 import com.google.gerrit.server.group.db.Groups;
@@ -97,7 +97,7 @@
   }
 
   @Override
-  public GroupMembership membershipsOf(IdentifiedUser user) {
+  public GroupMembership membershipsOf(CurrentUser user) {
     return groupMembershipFactory.create(user);
   }
 
diff --git a/java/com/google/gerrit/server/account/ServiceUserClassifier.java b/java/com/google/gerrit/server/account/ServiceUserClassifier.java
index c8314c8..2d2a646 100644
--- a/java/com/google/gerrit/server/account/ServiceUserClassifier.java
+++ b/java/com/google/gerrit/server/account/ServiceUserClassifier.java
@@ -17,6 +17,11 @@
 import com.google.gerrit.entities.Account;
 
 public interface ServiceUserClassifier {
+  /**
+   * Name of the Service Users group used by this class to determine whether an account is a service
+   * user; if an account is a part of this group, that account is considered a service user.
+   */
+  public static final String SERVICE_USERS = "Service Users";
   /** Returns {@code true} if the given user is considered a {@code Service User} user. */
   boolean isServiceUser(Account.Id user);
 
diff --git a/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java b/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
index 255467c..3ee2c54 100644
--- a/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
+++ b/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
@@ -63,7 +63,7 @@
 
   @Override
   public boolean isServiceUser(Account.Id user) {
-    Optional<InternalGroup> maybeGroup = groupCache.get(AccountGroup.nameKey("Service Users"));
+    Optional<InternalGroup> maybeGroup = groupCache.get(AccountGroup.nameKey(SERVICE_USERS));
     if (!maybeGroup.isPresent()) {
       return false;
     }
diff --git a/java/com/google/gerrit/server/account/UniversalGroupBackend.java b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
index a35b0ac..5bd9bea 100644
--- a/java/com/google/gerrit/server/account/UniversalGroupBackend.java
+++ b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
-import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.StartupCheck;
 import com.google.gerrit.server.StartupException;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -94,14 +94,14 @@
   }
 
   @Override
-  public GroupMembership membershipsOf(IdentifiedUser user) {
+  public GroupMembership membershipsOf(CurrentUser user) {
     return new UniversalGroupMembership(user);
   }
 
   private class UniversalGroupMembership implements GroupMembership {
     private final Map<GroupBackend, GroupMembership> memberships;
 
-    private UniversalGroupMembership(IdentifiedUser user) {
+    private UniversalGroupMembership(CurrentUser user) {
       ImmutableMap.Builder<GroupBackend, GroupMembership> builder = ImmutableMap.builder();
       backends.runEach(g -> builder.put(g, g.membershipsOf(user)));
       this.memberships = builder.build();
diff --git a/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java b/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
index 235537c..30021e6 100644
--- a/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
+++ b/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
@@ -61,6 +61,8 @@
  * <p>Other comment lines are ignored on read, and are not written back when the file is modified.
  */
 public class VersionedAuthorizedKeys extends VersionedMetaData {
+
+  /** Read/write SSH keys by user ID. */
   @Singleton
   public static class Accessor {
     private final GitRepositoryManager repoManager;
diff --git a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index 4f85412..1eee10f 100644
--- a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -365,8 +365,7 @@
   @Override
   public void starChange(String changeId) throws RestApiException {
     try {
-      starredChangesCreate.apply(
-          account, IdString.fromUrl(changeId), new StarredChanges.EmptyInput());
+      starredChangesCreate.apply(account, IdString.fromUrl(changeId), new Input());
     } catch (Exception e) {
       throw asRestApiException("Cannot star change", e);
     }
@@ -378,7 +377,7 @@
       ChangeResource rsrc = changes.parse(TopLevelResource.INSTANCE, IdString.fromUrl(changeId));
       AccountResource.StarredChange starredChange =
           new AccountResource.StarredChange(account.getUser(), rsrc);
-      starredChangesDelete.apply(starredChange, new StarredChanges.EmptyInput());
+      starredChangesDelete.apply(starredChange, new Input());
     } catch (Exception e) {
       throw asRestApiException("Cannot unstar change", e);
     }
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index 0992bcd..8047e0e 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -177,6 +177,8 @@
   private final Provider<GetPureRevert> getPureRevertProvider;
   private final StarredChangesUtil stars;
   private final DynamicOptionParser dynamicOptionParser;
+  private final Injector injector;
+  private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
 
   @Inject
   ChangeApiImpl(
@@ -230,7 +232,9 @@
       Provider<GetPureRevert> getPureRevertProvider,
       StarredChangesUtil stars,
       DynamicOptionParser dynamicOptionParser,
-      @Assisted ChangeResource change) {
+      @Assisted ChangeResource change,
+      Injector injector,
+      DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
     this.changeApi = changeApi;
     this.revert = revert;
     this.revertSubmission = revertSubmission;
@@ -282,6 +286,8 @@
     this.stars = stars;
     this.dynamicOptionParser = dynamicOptionParser;
     this.change = change;
+    this.injector = injector;
+    this.dynamicBeans = dynamicBeans;
   }
 
   @Override
@@ -500,10 +506,10 @@
   public ChangeInfo get(
       EnumSet<ListChangesOption> options, ImmutableListMultimap<String, String> pluginOptions)
       throws RestApiException {
-    try {
+    try (DynamicOptions dynamicOptions = new DynamicOptions(injector, dynamicBeans)) {
       GetChange getChange = getChangeProvider.get();
       options.forEach(getChange::addOption);
-      dynamicOptionParser.parseDynamicOptions(getChange, pluginOptions);
+      dynamicOptionParser.parseDynamicOptions(getChange, pluginOptions, dynamicOptions);
       return getChange.apply(change).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve change", e);
@@ -606,6 +612,7 @@
         try {
           ListChangeComments listComments = listCommentsProvider.get();
           listComments.setContext(this.getContext());
+          listComments.setContextPadding(this.getContextPadding());
           return listComments.apply(change).value();
         } catch (Exception e) {
           throw asRestApiException("Cannot get comments", e);
@@ -617,6 +624,7 @@
         try {
           ListChangeComments listComments = listCommentsProvider.get();
           listComments.setContext(this.getContext());
+          listComments.setContextPadding(this.getContextPadding());
           return listComments.getComments(change);
         } catch (Exception e) {
           throw asRestApiException("Cannot get comments", e);
@@ -759,23 +767,18 @@
   @Singleton
   static class DynamicOptionParser {
     private final CmdLineParser.Factory cmdLineParserFactory;
-    private final Injector injector;
-    private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
 
     @Inject
-    DynamicOptionParser(
-        CmdLineParser.Factory cmdLineParserFactory,
-        Injector injector,
-        DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
+    DynamicOptionParser(CmdLineParser.Factory cmdLineParserFactory) {
       this.cmdLineParserFactory = cmdLineParserFactory;
-      this.injector = injector;
-      this.dynamicBeans = dynamicBeans;
     }
 
-    void parseDynamicOptions(Object bean, ListMultimap<String, String> pluginOptions)
+    void parseDynamicOptions(
+        Object bean, ListMultimap<String, String> pluginOptions, DynamicOptions dynamicOptions)
         throws BadRequestException {
       CmdLineParser clp = cmdLineParserFactory.create(bean);
-      DynamicOptions dynamicOptions = new DynamicOptions(bean, injector, dynamicBeans);
+      dynamicOptions.setBean(bean);
+      dynamicOptions.startLifecycleListeners();
       dynamicOptions.parseDynamicBeans(clp);
       dynamicOptions.setDynamicBeans();
       dynamicOptions.onBeanParseStart();
diff --git a/java/com/google/gerrit/server/api/changes/ChangesImpl.java b/java/com/google/gerrit/server/api/changes/ChangesImpl.java
index d6ef61c..0596524 100644
--- a/java/com/google/gerrit/server/api/changes/ChangesImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangesImpl.java
@@ -26,15 +26,18 @@
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.api.changes.ChangeApiImpl.DynamicOptionParser;
 import com.google.gerrit.server.restapi.change.ChangesCollection;
 import com.google.gerrit.server.restapi.change.CreateChange;
 import com.google.gerrit.server.restapi.change.QueryChanges;
 import com.google.inject.Inject;
+import com.google.inject.Injector;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.util.List;
@@ -46,6 +49,8 @@
   private final CreateChange createChange;
   private final DynamicOptionParser dynamicOptionParser;
   private final Provider<QueryChanges> queryProvider;
+  private final Injector injector;
+  private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
 
   @Inject
   ChangesImpl(
@@ -53,12 +58,16 @@
       ChangeApiImpl.Factory api,
       CreateChange createChange,
       DynamicOptionParser dynamicOptionParser,
-      Provider<QueryChanges> queryProvider) {
+      Provider<QueryChanges> queryProvider,
+      Injector injector,
+      DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
     this.changes = changes;
     this.api = api;
     this.createChange = createChange;
     this.dynamicOptionParser = dynamicOptionParser;
     this.queryProvider = queryProvider;
+    this.injector = injector;
+    this.dynamicBeans = dynamicBeans;
   }
 
   @Override
@@ -123,34 +132,36 @@
   }
 
   private List<ChangeInfo> get(QueryRequest q) throws RestApiException {
-    QueryChanges qc = queryProvider.get();
-    if (q.getQuery() != null) {
-      qc.addQuery(q.getQuery());
-    }
-    qc.setLimit(q.getLimit());
-    qc.setStart(q.getStart());
-    qc.setNoLimit(q.getNoLimit());
-    for (ListChangesOption option : q.getOptions()) {
-      qc.addOption(option);
-    }
-    dynamicOptionParser.parseDynamicOptions(qc, q.getPluginOptions());
-
-    try {
-      List<?> result = qc.apply(TopLevelResource.INSTANCE).value();
-      if (result.isEmpty()) {
-        return ImmutableList.of();
+    try (DynamicOptions dynamicOptions = new DynamicOptions(injector, dynamicBeans)) {
+      QueryChanges qc = queryProvider.get();
+      if (q.getQuery() != null) {
+        qc.addQuery(q.getQuery());
       }
+      qc.setLimit(q.getLimit());
+      qc.setStart(q.getStart());
+      qc.setNoLimit(q.getNoLimit());
+      for (ListChangesOption option : q.getOptions()) {
+        qc.addOption(option);
+      }
+      dynamicOptionParser.parseDynamicOptions(qc, q.getPluginOptions(), dynamicOptions);
 
-      // Check type safety of result; the extension API should be safer than the
-      // REST API in this case, since it's intended to be used in Java.
-      Object first = requireNonNull(result.iterator().next());
-      checkState(first instanceof ChangeInfo);
-      @SuppressWarnings("unchecked")
-      List<ChangeInfo> infos = (List<ChangeInfo>) result;
+      try {
+        List<?> result = qc.apply(TopLevelResource.INSTANCE).value();
+        if (result.isEmpty()) {
+          return ImmutableList.of();
+        }
 
-      return ImmutableList.copyOf(infos);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot query changes", e);
+        // Check type safety of result; the extension API should be safer than the
+        // REST API in this case, since it's intended to be used in Java.
+        Object first = requireNonNull(result.iterator().next());
+        checkState(first instanceof ChangeInfo);
+        @SuppressWarnings("unchecked")
+        List<ChangeInfo> infos = (List<ChangeInfo>) result;
+
+        return ImmutableList.copyOf(infos);
+      } catch (Exception e) {
+        throw asRestApiException("Cannot query changes", e);
+      }
     }
   }
 }
diff --git a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index 04d2e8ae..573f2f5 100644
--- a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -106,7 +106,7 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 
-class RevisionApiImpl implements RevisionApi {
+class RevisionApiImpl extends RevisionApi.NotImplemented {
   interface Factory {
     RevisionApiImpl create(RevisionResource r);
   }
@@ -282,7 +282,16 @@
   @Override
   public ChangeApi rebase(RebaseInput in) throws RestApiException {
     try {
-      return changes.id(rebase.apply(revision, in).value()._number);
+      return changes.id(rebaseAsInfo(in)._number);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot rebase ps", e);
+    }
+  }
+
+  @Override
+  public ChangeInfo rebaseAsInfo(RebaseInput in) throws RestApiException {
+    try {
+      return rebase.apply(revision, in).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot rebase ps", e);
     }
@@ -678,11 +687,6 @@
   }
 
   @Override
-  public String etag() throws RestApiException {
-    return revisionActions.getETag(revision);
-  }
-
-  @Override
   public BinaryResult getArchive(ArchiveFormat format) throws RestApiException {
     GetArchive getArchive = getArchiveProvider.get();
     getArchive.setFormat(format != null ? format.name().toLowerCase(Locale.US) : null);
diff --git a/java/com/google/gerrit/server/api/projects/BranchApiImpl.java b/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
index c7cca6f..78f5c5f 100644
--- a/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
@@ -16,6 +16,7 @@
 
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.BranchInput;
@@ -126,6 +127,10 @@
 
   private BranchResource resource()
       throws RestApiException, IOException, PermissionBackendException {
-    return branches.parse(project, IdString.fromDecoded(ref));
+    String refName = ref;
+    if (RefNames.isRefsUsersSelf(ref, project.getProjectState().isAllUsers())) {
+      refName = RefNames.refsUsers(project.getUser().getAccountId());
+    }
+    return branches.parse(project, IdString.fromDecoded(refName));
   }
 }
diff --git a/java/com/google/gerrit/server/api/projects/CommitApiImpl.java b/java/com/google/gerrit/server/api/projects/CommitApiImpl.java
index 5c7921a..e055a00 100644
--- a/java/com/google/gerrit/server/api/projects/CommitApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/CommitApiImpl.java
@@ -22,13 +22,16 @@
 import com.google.gerrit.extensions.api.changes.IncludedInInfo;
 import com.google.gerrit.extensions.api.projects.CommitApi;
 import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.project.CommitResource;
 import com.google.gerrit.server.restapi.change.CherryPickCommit;
 import com.google.gerrit.server.restapi.project.CommitIncludedIn;
+import com.google.gerrit.server.restapi.project.FilesInCommitCollection;
 import com.google.gerrit.server.restapi.project.GetCommit;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import java.util.Map;
 
 public class CommitApiImpl implements CommitApi {
   public interface Factory {
@@ -40,6 +43,7 @@
   private final CherryPickCommit cherryPickCommit;
   private final CommitIncludedIn includedIn;
   private final CommitResource commitResource;
+  private final FilesInCommitCollection.ListFiles listFiles;
 
   @Inject
   CommitApiImpl(
@@ -47,11 +51,13 @@
       GetCommit getCommit,
       CherryPickCommit cherryPickCommit,
       CommitIncludedIn includedIn,
+      FilesInCommitCollection.ListFiles listFiles,
       @Assisted CommitResource commitResource) {
     this.changes = changes;
     this.getCommit = getCommit;
     this.cherryPickCommit = cherryPickCommit;
     this.includedIn = includedIn;
+    this.listFiles = listFiles;
     this.commitResource = commitResource;
   }
 
@@ -81,4 +87,13 @@
       throw asRestApiException("Could not extract IncludedIn data", e);
     }
   }
+
+  @Override
+  public Map<String, FileInfo> files(int parentNum) throws RestApiException {
+    try {
+      return listFiles.setParent(parentNum).apply(commitResource).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve files", e);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java b/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java
new file mode 100644
index 0000000..9553acc
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java
@@ -0,0 +1,105 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache;
+
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Base class for persistent cache factory. If the cache.directory property is unset, or disk limit
+ * is zero or negative, it will fall back to in-memory only caches.
+ */
+public abstract class PersistentCacheBaseFactory implements PersistentCacheFactory {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  protected final MemoryCacheFactory memCacheFactory;
+  protected final Path cacheDir;
+  protected boolean diskEnabled;
+  protected final Config config;
+
+  public PersistentCacheBaseFactory(
+      MemoryCacheFactory memCacheFactory, @GerritServerConfig Config config, SitePaths site) {
+    this.cacheDir = getCacheDir(site, config.getString("cache", null, "directory"));
+    this.diskEnabled = cacheDir != null;
+    this.memCacheFactory = memCacheFactory;
+    this.config = config;
+  }
+
+  protected abstract <K, V> Cache<K, V> buildImpl(
+      PersistentCacheDef<K, V> in, long diskLimit, CacheBackend backend);
+
+  protected abstract <K, V> LoadingCache<K, V> buildImpl(
+      PersistentCacheDef<K, V> in, CacheLoader<K, V> loader, long diskLimit, CacheBackend backend);
+
+  @Override
+  public <K, V> Cache<K, V> build(PersistentCacheDef<K, V> in, CacheBackend backend) {
+    long limit = getDiskLimit(in);
+
+    if (isInMemoryCache(limit)) {
+      return memCacheFactory.build(in, backend);
+    }
+
+    return buildImpl(in, limit, backend);
+  }
+
+  @Override
+  public <K, V> LoadingCache<K, V> build(
+      PersistentCacheDef<K, V> in, CacheLoader<K, V> loader, CacheBackend backend) {
+    long limit = getDiskLimit(in);
+
+    if (isInMemoryCache(limit)) {
+      return memCacheFactory.build(in, loader, backend);
+    }
+
+    return buildImpl(in, loader, limit, backend);
+  }
+
+  private <K, V> long getDiskLimit(PersistentCacheDef<K, V> in) {
+    return config.getLong("cache", in.configKey(), "diskLimit", in.diskLimit());
+  }
+
+  private <K, V> boolean isInMemoryCache(long diskLimit) {
+    return !diskEnabled || diskLimit <= 0;
+  }
+
+  private static Path getCacheDir(SitePaths site, String name) {
+    if (name == null) {
+      return null;
+    }
+    Path loc = site.resolve(name);
+    if (!Files.exists(loc)) {
+      try {
+        Files.createDirectories(loc);
+      } catch (IOException e) {
+        logger.atWarning().log("Can't create disk cache: %s", loc.toAbsolutePath());
+        return null;
+      }
+    }
+    if (!Files.isWritable(loc)) {
+      logger.atWarning().log("Can't write to disk cache: %s", loc.toAbsolutePath());
+      return null;
+    }
+    logger.atInfo().log("Enabling disk cache %s", loc.toAbsolutePath());
+    return loc;
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
index 82615a4..16d62b3 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
@@ -23,8 +23,8 @@
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.server.cache.CacheBackend;
 import com.google.gerrit.server.cache.MemoryCacheFactory;
+import com.google.gerrit.server.cache.PersistentCacheBaseFactory;
 import com.google.gerrit.server.cache.PersistentCacheDef;
-import com.google.gerrit.server.cache.PersistentCacheFactory;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.SqlStore;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.ValueHolder;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -34,9 +34,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
@@ -52,12 +49,9 @@
  * is unset, it will fall back to in-memory caches.
  */
 @Singleton
-class H2CacheFactory implements PersistentCacheFactory, LifecycleListener {
+class H2CacheFactory extends PersistentCacheBaseFactory implements LifecycleListener {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final MemoryCacheFactory memCacheFactory;
-  private final Config config;
-  private final Path cacheDir;
   private final List<H2CacheImpl<?, ?>> caches;
   private final DynamicMap<Cache<?, ?>> cacheMap;
   private final ExecutorService executor;
@@ -71,15 +65,13 @@
       @GerritServerConfig Config cfg,
       SitePaths site,
       DynamicMap<Cache<?, ?>> cacheMap) {
-    this.memCacheFactory = memCacheFactory;
-    config = cfg;
-    cacheDir = getCacheDir(site, cfg.getString("cache", null, "directory"));
+    super(memCacheFactory, cfg, site);
     h2CacheSize = cfg.getLong("cache", null, "h2CacheSize", -1);
     h2AutoServer = cfg.getBoolean("cache", null, "h2AutoServer", false);
     caches = new LinkedList<>();
     this.cacheMap = cacheMap;
 
-    if (cacheDir != null) {
+    if (diskEnabled) {
       executor =
           new LoggingContextAwareExecutorService(
               Executors.newFixedThreadPool(
@@ -98,27 +90,6 @@
     }
   }
 
-  private static Path getCacheDir(SitePaths site, String name) {
-    if (name == null) {
-      return null;
-    }
-    Path loc = site.resolve(name);
-    if (!Files.exists(loc)) {
-      try {
-        Files.createDirectories(loc);
-      } catch (IOException e) {
-        logger.atWarning().log("Can't create disk cache: %s", loc.toAbsolutePath());
-        return null;
-      }
-    }
-    if (!Files.isWritable(loc)) {
-      logger.atWarning().log("Can't write to disk cache: %s", loc.toAbsolutePath());
-      return null;
-    }
-    logger.atInfo().log("Enabling disk cache %s", loc.toAbsolutePath());
-    return loc;
-  }
-
   @Override
   public void start() {
     if (executor != null) {
@@ -161,13 +132,8 @@
 
   @SuppressWarnings({"unchecked"})
   @Override
-  public <K, V> Cache<K, V> build(PersistentCacheDef<K, V> in, CacheBackend backend) {
-    long limit = config.getLong("cache", in.configKey(), "diskLimit", in.diskLimit());
-
-    if (cacheDir == null || limit <= 0) {
-      return memCacheFactory.build(in, backend);
-    }
-
+  public <K, V> Cache<K, V> buildImpl(
+      PersistentCacheDef<K, V> in, long limit, CacheBackend backend) {
     H2CacheDefProxy<K, V> def = new H2CacheDefProxy<>(in);
     SqlStore<K, V> store = newSqlStore(def, limit);
     H2CacheImpl<K, V> cache =
@@ -184,14 +150,8 @@
 
   @SuppressWarnings("unchecked")
   @Override
-  public <K, V> LoadingCache<K, V> build(
-      PersistentCacheDef<K, V> in, CacheLoader<K, V> loader, CacheBackend backend) {
-    long limit = config.getLong("cache", in.configKey(), "diskLimit", in.diskLimit());
-
-    if (cacheDir == null || limit <= 0) {
-      return memCacheFactory.build(in, loader, backend);
-    }
-
+  public <K, V> LoadingCache<K, V> buildImpl(
+      PersistentCacheDef<K, V> in, CacheLoader<K, V> loader, long limit, CacheBackend backend) {
     H2CacheDefProxy<K, V> def = new H2CacheDefProxy<>(in);
     SqlStore<K, V> store = newSqlStore(def, limit);
     Cache<K, ValueHolder<V>> mem =
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/BUILD b/java/com/google/gerrit/server/cache/serialize/entities/BUILD
index cb8c4ae..55080e8 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/BUILD
+++ b/java/com/google/gerrit/server/cache/serialize/entities/BUILD
@@ -5,12 +5,8 @@
     srcs = glob(["*.java"]),
     visibility = ["//visibility:public"],
     deps = [
-        "//java/com/google/gerrit/common:annotations",
-        "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
-        "//java/com/google/gerrit/git",
-        "//java/com/google/gerrit/proto",
         "//java/com/google/gerrit/server/cache/serialize",
         "//lib:guava",
         "//lib:jgit",
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
index 291db4a..4627cdb 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
@@ -42,6 +42,8 @@
         .setCopyAnyScore(proto.getCopyAnyScore())
         .setCopyMinScore(proto.getCopyMinScore())
         .setCopyMaxScore(proto.getCopyMaxScore())
+        .setCopyAllScoresIfListOfFilesDidNotChange(
+            proto.getCopyAllScoresIfListOfFilesDidNotChange())
         .setCopyAllScoresOnMergeFirstParentUpdate(proto.getCopyAllScoresOnMergeFirstParentUpdate())
         .setCopyAllScoresOnTrivialRebase(proto.getCopyAllScoresOnTrivialRebase())
         .setCopyAllScoresIfNoCodeChange(proto.getCopyAllScoresIfNoCodeChange())
@@ -68,6 +70,8 @@
         .setCopyAnyScore(autoValue.isCopyAnyScore())
         .setCopyMinScore(autoValue.isCopyMinScore())
         .setCopyMaxScore(autoValue.isCopyMaxScore())
+        .setCopyAllScoresIfListOfFilesDidNotChange(
+            autoValue.isCopyAllScoresIfListOfFilesDidNotChange())
         .setCopyAllScoresOnMergeFirstParentUpdate(
             autoValue.isCopyAllScoresOnMergeFirstParentUpdate())
         .setCopyAllScoresOnTrivialRebase(autoValue.isCopyAllScoresOnTrivialRebase())
diff --git a/java/com/google/gerrit/server/change/ChangeAttributeFactory.java b/java/com/google/gerrit/server/change/ChangeAttributeFactory.java
deleted file mode 100644
index 663d7aa..0000000
--- a/java/com/google/gerrit/server/change/ChangeAttributeFactory.java
+++ /dev/null
@@ -1,49 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.common.PluginDefinedInfo;
-import com.google.gerrit.server.DynamicOptions.BeanProvider;
-import com.google.gerrit.server.query.change.ChangeData;
-
-/**
- * Interface for plugins to provide additional fields in {@link
- * com.google.gerrit.extensions.common.ChangeInfo ChangeInfo}.
- *
- * <p>Register a {@code ChangeAttributeFactory} in a plugin {@code Module} like this:
- *
- * <pre>
- * DynamicSet.bind(binder(), ChangeAttributeFactory.class).to(YourClass.class);
- * </pre>
- *
- * <p>See the <a
- * href="https://gerrit-review.googlesource.com/Documentation/dev-plugins.html#query_attributes">plugin
- * developer documentation for more details and examples.
- */
-@Deprecated
-public interface ChangeAttributeFactory {
-
-  /**
-   * Create a plugin-provided info field.
-   *
-   * <p>Typically, implementations will subclass {@code PluginDefinedInfo} to add additional fields.
-   *
-   * @param cd change.
-   * @param beanProvider provider of {@code DynamicBean}s, which may be used for reading options.
-   * @param plugin plugin name.
-   * @return the plugin's special change info.
-   */
-  PluginDefinedInfo create(ChangeData cd, BeanProvider beanProvider, String plugin);
-}
diff --git a/java/com/google/gerrit/server/change/ChangeETagComputation.java b/java/com/google/gerrit/server/change/ChangeETagComputation.java
index a5b7d49..2fd5755 100644
--- a/java/com/google/gerrit/server/change/ChangeETagComputation.java
+++ b/java/com/google/gerrit/server/change/ChangeETagComputation.java
@@ -26,7 +26,7 @@
  * <ul>
  *   <li>providing plugin defined attributes to {@link
  *       com.google.gerrit.extensions.common.ChangeInfo#plugins} (see {@link
- *       ChangeAttributeFactory})
+ *       ChangePluginDefinedInfoFactory})
  *   <li>implementing a {@link com.google.gerrit.server.rules.SubmitRule} which affects the
  *       computation of {@link com.google.gerrit.extensions.common.ChangeInfo#submittable}
  * </ul>
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 7b2663a..c7aedb6 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -47,6 +47,7 @@
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
@@ -54,6 +55,7 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.entities.SubmitRecord.Status;
 import com.google.gerrit.entities.SubmitRequirement;
@@ -75,6 +77,7 @@
 import com.google.gerrit.extensions.common.SubmitRequirementInfo;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
 import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.query.QueryResult;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
@@ -112,11 +115,14 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
 
 /**
  * Produces {@link ChangeInfo} (which is serialized to JSON afterwards) from {@link ChangeData}.
@@ -158,17 +164,12 @@
     }
 
     public ChangeJson create(Iterable<ListChangesOption> options) {
-      return factory.create(options, Optional.empty(), Optional.empty());
+      return factory.create(options, Optional.empty());
     }
 
     public ChangeJson create(
-        Iterable<ListChangesOption> options,
-        PluginDefinedAttributesFactory pluginDefinedAttributesFactory,
-        PluginDefinedInfosFactory pluginDefinedInfosFactory) {
-      return factory.create(
-          options,
-          Optional.of(pluginDefinedAttributesFactory),
-          Optional.of(pluginDefinedInfosFactory));
+        Iterable<ListChangesOption> options, PluginDefinedInfosFactory pluginDefinedInfosFactory) {
+      return factory.create(options, Optional.of(pluginDefinedInfosFactory));
     }
 
     public ChangeJson create(ListChangesOption first, ListChangesOption... rest) {
@@ -179,7 +180,6 @@
   public interface AssistedFactory {
     ChangeJson create(
         Iterable<ListChangesOption> options,
-        Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory,
         Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory);
   }
 
@@ -226,7 +226,6 @@
   private final TrackingFooters trackingFooters;
   private final Metrics metrics;
   private final RevisionJson revisionJson;
-  private final Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory;
   private final Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory;
   private final boolean includeMergeable;
   private final boolean lazyLoad;
@@ -244,14 +243,13 @@
       Provider<ConsistencyChecker> checkerProvider,
       ActionJson actionJson,
       ChangeNotes.Factory notesFactory,
-      LabelsJson.Factory labelsJsonFactory,
+      LabelsJson labelsJson,
       RemoveReviewerControl removeReviewerControl,
       TrackingFooters trackingFooters,
       Metrics metrics,
       RevisionJson.Factory revisionJsonFactory,
       @GerritServerConfig Config cfg,
       @Assisted Iterable<ListChangesOption> options,
-      @Assisted Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory,
       @Assisted Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory) {
     this.userProvider = user;
     this.changeDataFactory = cdf;
@@ -261,7 +259,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;
@@ -269,7 +267,6 @@
     this.options = Sets.immutableEnumSet(options);
     this.includeMergeable = MergeabilityComputationBehavior.fromConfig(cfg).includeInApi();
     this.lazyLoad = containsAnyOf(this.options, REQUIRE_LAZY_LOAD);
-    this.pluginDefinedAttributesFactory = pluginDefinedAttributesFactory;
     this.pluginDefinedInfosFactory = pluginDefinedInfosFactory;
 
     logger.atFine().log("options = %s", options);
@@ -288,6 +285,11 @@
     return format(changeDataFactory.create(change));
   }
 
+  public ChangeInfo format(Change change, @Nullable ObjectId metaRevId) {
+    ChangeNotes notes = notesFactory.createChecked(change.getProject(), change.getId(), metaRevId);
+    return format(changeDataFactory.create(notes));
+  }
+
   public ChangeInfo format(ChangeData cd) {
     return format(cd, Optional.empty(), true, getPluginInfos(cd));
   }
@@ -330,9 +332,13 @@
   }
 
   public ChangeInfo format(Project.NameKey project, Change.Id id) {
+    return format(project, id, null);
+  }
+
+  public ChangeInfo format(Project.NameKey project, Change.Id id, @Nullable ObjectId metaRevId) {
     ChangeNotes notes;
     try {
-      notes = notesFactory.createChecked(project, id);
+      notes = notesFactory.createChecked(project, id, metaRevId);
     } catch (StorageException e) {
       if (!has(CHECK)) {
         throw e;
@@ -577,6 +583,15 @@
     out.totalCommentCount = cd.totalCommentCount();
     out.unresolvedCommentCount = cd.unresolvedCommentCount();
 
+    if (cd.getRefStates() != null) {
+      String metaName = RefNames.changeMetaRef(cd.getId());
+      Optional<RefState> metaState =
+          cd.getRefStates().values().stream().filter(r -> r.ref().equals(metaName)).findAny();
+
+      // metaState should always be there, but it doesn't hurt to be extra careful.
+      metaState.ifPresent(rs -> out.metaRevId = rs.id().getName());
+    }
+
     if (user.isIdentifiedUser()) {
       Collection<String> stars = cd.stars(user.getAccountId());
       out.starred = stars.contains(StarredChangesUtil.DEFAULT_LABEL) ? true : null;
@@ -611,17 +626,9 @@
     }
 
     setSubmitter(cd, out);
-    if (pluginDefinedAttributesFactory.isPresent()) {
-      out.plugins = pluginDefinedAttributesFactory.get().create(cd);
-    }
 
     if (!pluginInfos.isEmpty()) {
-      if (out.plugins == null) {
-        out.plugins = pluginInfos;
-      } else {
-        out.plugins = new ArrayList<>(out.plugins);
-        out.plugins.addAll(pluginInfos);
-      }
+      out.plugins = pluginInfos;
     }
     out.revertOf = cd.change().getRevertOf() != null ? cd.change().getRevertOf().get() : null;
     out.submissionId = cd.change().getSubmissionId();
@@ -749,7 +756,14 @@
     // removed.
     Collection<LabelInfo> labels = out.labels.values();
     Set<Account.Id> fixed = Sets.newHashSetWithExpectedSize(labels.size());
-    Set<Account.Id> removable = Sets.newHashSetWithExpectedSize(labels.size());
+    Set<Account.Id> removable = new HashSet<>();
+
+    // Add all reviewers, which will later be removed if they are in the "fixed" set.
+    removable.addAll(
+        out.reviewers.getOrDefault(ReviewerState.REVIEWER, Collections.emptySet()).stream()
+            .filter(a -> a._accountId != null)
+            .map(a -> Account.id(a._accountId))
+            .collect(Collectors.toSet()));
 
     // Check if the user has the permission to remove a reviewer. This means we can bypass the
     // testRemoveReviewer check for a specific reviewer in the loop saving potentially many
@@ -766,11 +780,9 @@
       for (ApprovalInfo ai : label.all) {
         Account.Id id = Account.id(ai._accountId);
 
-        if (canRemoveAnyReviewer
-            || removeReviewerControl.testRemoveReviewer(
+        if (!canRemoveAnyReviewer
+            && !removeReviewerControl.testRemoveReviewer(
                 cd, userProvider.get(), id, MoreObjects.firstNonNull(ai.value, 0))) {
-          removable.add(id);
-        } else {
           fixed.add(id);
         }
       }
diff --git a/java/com/google/gerrit/server/change/ChangeKeyAdapter.java b/java/com/google/gerrit/server/change/ChangeKeyAdapter.java
deleted file mode 100644
index 0db4cea..0000000
--- a/java/com/google/gerrit/server/change/ChangeKeyAdapter.java
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.entities.Change;
-import com.google.gson.JsonDeserializationContext;
-import com.google.gson.JsonDeserializer;
-import com.google.gson.JsonElement;
-import com.google.gson.JsonObject;
-import com.google.gson.JsonParseException;
-import com.google.gson.JsonSerializationContext;
-import com.google.gson.JsonSerializer;
-import java.lang.reflect.Type;
-
-/**
- * Adapter that serializes {@link com.google.gerrit.entities.Change.Key}'s {@code key} field as
- * {@code id}, for backwards compatibility in stream-events.
- */
-// TODO(dborowitz): auto-value-gson should support this directly using @SerializedName on the
-// AutoValue method.
-public class ChangeKeyAdapter implements JsonSerializer<Change.Key>, JsonDeserializer<Change.Key> {
-  @Override
-  public JsonElement serialize(Change.Key src, Type typeOfSrc, JsonSerializationContext context) {
-    JsonObject obj = new JsonObject();
-    obj.addProperty("id", src.get());
-    return obj;
-  }
-
-  @Override
-  public Change.Key deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
-      throws JsonParseException {
-    JsonElement keyJson = json.getAsJsonObject().get("id");
-    if (keyJson == null || !keyJson.isJsonPrimitive() || !keyJson.getAsJsonPrimitive().isString()) {
-      throw new JsonParseException("Key is not a string: " + keyJson);
-    }
-    String key = keyJson.getAsJsonPrimitive().getAsString();
-    return Change.key(key);
-  }
-}
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index 07cb04f..bf00d27 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.server.mail.send.DeleteReviewerSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.RemoveReviewerControl;
@@ -132,9 +133,15 @@
     for (LabelType lt : labelTypes.getLabelTypes()) {
       newApprovals.put(lt.getName(), (short) 0);
     }
-
+    String ccOrReviewer =
+        approvalsUtil
+                .getReviewers(ctx.getNotes())
+                .byState(ReviewerStateInternal.CC)
+                .contains(reviewerId)
+            ? "cc"
+            : "reviewer";
     StringBuilder msg = new StringBuilder();
-    msg.append("Removed reviewer " + reviewer.account().fullName());
+    msg.append(String.format("Removed %s %s", ccOrReviewer, reviewer.account().fullName()));
     StringBuilder removedVotesMsg = new StringBuilder();
     removedVotesMsg.append(" with the following votes:\n\n");
     boolean votesRemoved = false;
diff --git a/java/com/google/gerrit/server/change/FileInfoJson.java b/java/com/google/gerrit/server/change/FileInfoJson.java
index aca4fb0..ad6f9c7 100644
--- a/java/com/google/gerrit/server/change/FileInfoJson.java
+++ b/java/com/google/gerrit/server/change/FileInfoJson.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2013 The Android Open Source Project
+// Copyright (C) 2021 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -16,108 +16,67 @@
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.server.patch.PatchList;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListEntry;
-import com.google.gerrit.server.patch.PatchListKey;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
 import java.util.Map;
-import java.util.TreeMap;
-import java.util.concurrent.ExecutionException;
-import org.eclipse.jgit.errors.NoMergeBaseException;
 import org.eclipse.jgit.lib.ObjectId;
 
-@Singleton
-public class FileInfoJson {
-  private final PatchListCache patchListCache;
+/** Compute and return the list of modified files between two commits. */
+public interface FileInfoJson {
 
-  @Inject
-  FileInfoJson(PatchListCache patchListCache) {
-    this.patchListCache = patchListCache;
-  }
-
-  public Map<String, FileInfo> toFileInfoMap(Change change, PatchSet patchSet)
+  /**
+   * Computes the list of modified files for a given change and patchset against the parent commit.
+   *
+   * @param change a Gerrit change.
+   * @param patchSet a single revision of the change.
+   * @return a mapping of the file paths to their related diff information.
+   */
+  default Map<String, FileInfo> getFileInfoMap(Change change, PatchSet patchSet)
       throws ResourceConflictException, PatchListNotAvailableException {
-    return toFileInfoMap(change, patchSet.commitId(), null);
+    return getFileInfoMap(change, patchSet.commitId(), null);
   }
 
-  public Map<String, FileInfo> toFileInfoMap(
-      Change change, ObjectId objectId, @Nullable PatchSet base)
+  /**
+   * Computes the list of modified files for a given change and patchset against its parent. For
+   * merge commits, callers can use 0, 1, 2, etc... to choose a specific parent. The first parent is
+   * 0.
+   *
+   * @param change a Gerrit change.
+   * @param objectId a commit SHA-1 identifying a patchset commit.
+   * @param parentNum an integer identifying the parent number used for comparison.
+   * @return a mapping of the file paths to their related diff information.
+   */
+  default Map<String, FileInfo> getFileInfoMap(Change change, ObjectId objectId, int parentNum)
       throws ResourceConflictException, PatchListNotAvailableException {
-    ObjectId a = base != null ? base.commitId() : null;
-    return toFileInfoMap(change, PatchListKey.againstCommit(a, objectId, Whitespace.IGNORE_NONE));
+    return getFileInfoMap(change.getProject(), objectId, parentNum);
   }
 
-  public Map<String, FileInfo> toFileInfoMap(Change change, ObjectId objectId, int parent)
-      throws ResourceConflictException, PatchListNotAvailableException {
-    return toFileInfoMap(
-        change, PatchListKey.againstParentNum(parent + 1, objectId, Whitespace.IGNORE_NONE));
-  }
+  /**
+   * Computes the list of modified files for a given change and patchset identified by its {@code
+   * objectId} against a specified base patchset.
+   *
+   * @param change a Gerrit change.
+   * @param objectId a commit SHA-1 identifying a patchset commit.
+   * @param base a base patchset to compare the commit identified by {@code objectId} against.
+   * @return a mapping of the file paths to their related diff information.
+   */
+  Map<String, FileInfo> getFileInfoMap(Change change, ObjectId objectId, @Nullable PatchSet base)
+      throws ResourceConflictException, PatchListNotAvailableException;
 
-  private Map<String, FileInfo> toFileInfoMap(Change change, PatchListKey key)
-      throws ResourceConflictException, PatchListNotAvailableException {
-    return toFileInfoMap(change.getProject(), key);
-  }
-
-  public Map<String, FileInfo> toFileInfoMap(Project.NameKey project, PatchListKey key)
-      throws ResourceConflictException, PatchListNotAvailableException {
-    PatchList list;
-    try {
-      list = patchListCache.get(key, project);
-    } catch (PatchListNotAvailableException e) {
-      Throwable cause = e.getCause();
-      if (cause instanceof ExecutionException) {
-        cause = cause.getCause();
-      }
-      if (cause instanceof NoMergeBaseException) {
-        throw new ResourceConflictException(
-            String.format("Cannot create auto merge commit: %s", e.getMessage()), e);
-      }
-      throw e;
-    }
-
-    Map<String, FileInfo> files = new TreeMap<>();
-    for (PatchListEntry e : list.getPatches()) {
-      FileInfo d = new FileInfo();
-      d.status =
-          e.getChangeType() != Patch.ChangeType.MODIFIED ? e.getChangeType().getCode() : null;
-      d.oldPath = e.getOldName();
-      d.sizeDelta = e.getSizeDelta();
-      d.size = e.getSize();
-      if (e.getPatchType() == Patch.PatchType.BINARY) {
-        d.binary = true;
-      } else {
-        d.linesInserted = e.getInsertions() > 0 ? e.getInsertions() : null;
-        d.linesDeleted = e.getDeletions() > 0 ? e.getDeletions() : null;
-      }
-
-      FileInfo o = files.put(e.getNewName(), d);
-      if (o != null) {
-        // This should only happen on a delete-add break created by JGit
-        // when the file was rewritten and too little content survived. Write
-        // a single record with data from both sides.
-        d.status = Patch.ChangeType.REWRITE.getCode();
-        d.sizeDelta = o.sizeDelta;
-        d.size = o.size;
-        if (o.binary != null && o.binary) {
-          d.binary = true;
-        }
-        if (o.linesInserted != null) {
-          d.linesInserted = o.linesInserted;
-        }
-        if (o.linesDeleted != null) {
-          d.linesDeleted = o.linesDeleted;
-        }
-      }
-    }
-    return files;
-  }
+  /**
+   * Computes the list of modified files for a given project and commit against its parent. For
+   * merge commits, callers can use 0, 1, 2, etc... to choose a specific parent. The first parent is
+   * 0. A value of -1 for parent can be passed to use the default base commit, which is the only
+   * parent for commits having only one parent, or the auto-merge otherwise.
+   *
+   * @param project a project identifying a repository.
+   * @param objectId a commit SHA-1 identifying a patchset commit.
+   * @param parentNum an integer identifying the parent number used for comparison.
+   * @return a mapping of the file paths to their related diff information.
+   */
+  Map<String, FileInfo> getFileInfoMap(Project.NameKey project, ObjectId objectId, int parentNum)
+      throws ResourceConflictException, PatchListNotAvailableException;
 }
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonModule.java b/java/com/google/gerrit/server/change/FileInfoJsonModule.java
new file mode 100644
index 0000000..f1c2e80
--- /dev/null
+++ b/java/com/google/gerrit/server/change/FileInfoJsonModule.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.AbstractModule;
+import org.eclipse.jgit.lib.Config;
+
+public class FileInfoJsonModule extends AbstractModule {
+  private final boolean useNewDiffCache;
+
+  public FileInfoJsonModule(@GerritServerConfig Config cfg) {
+    this.useNewDiffCache = cfg.getBoolean("cache", "diff_cache", "useNewDiffCache", false);
+  }
+
+  @Override
+  public void configure() {
+    bind(FileInfoJson.class)
+        .to(useNewDiffCache ? FileInfoJsonNewImpl.class : FileInfoJsonOldImpl.class);
+  }
+}
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java
new file mode 100644
index 0000000..0d3dcff
--- /dev/null
+++ b/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java
@@ -0,0 +1,108 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.inject.Inject;
+import java.util.HashMap;
+import java.util.Map;
+import org.eclipse.jgit.errors.NoMergeBaseException;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Implementation of {@link FileInfoJson} using the new diff cache {@link DiffOperations}. */
+public class FileInfoJsonNewImpl implements FileInfoJson {
+  private final DiffOperations diffs;
+
+  @Inject
+  FileInfoJsonNewImpl(DiffOperations diffOperations) {
+    this.diffs = diffOperations;
+  }
+
+  @Override
+  public Map<String, FileInfo> getFileInfoMap(
+      Change change, ObjectId objectId, @Nullable PatchSet base)
+      throws ResourceConflictException, PatchListNotAvailableException {
+    try {
+      if (base == null) {
+        return asFileInfo(
+            diffs.listModifiedFilesAgainstParent(change.getProject(), objectId, null));
+      }
+      return asFileInfo(diffs.listModifiedFiles(change.getProject(), base.commitId(), objectId));
+    } catch (DiffNotAvailableException e) {
+      convertException(e);
+      return null; // unreachable. handleAndThrow will throw an exception anyway
+    }
+  }
+
+  @Override
+  public Map<String, FileInfo> getFileInfoMap(
+      Project.NameKey project, ObjectId objectId, int parent)
+      throws ResourceConflictException, PatchListNotAvailableException {
+    try {
+      Map<String, FileDiffOutput> modifiedFiles =
+          diffs.listModifiedFilesAgainstParent(project, objectId, parent + 1);
+      return asFileInfo(modifiedFiles);
+    } catch (DiffNotAvailableException e) {
+      convertException(e);
+      return null; // unreachable. handleAndThrow will throw an exception anyway
+    }
+  }
+
+  private void convertException(DiffNotAvailableException e)
+      throws ResourceConflictException, PatchListNotAvailableException {
+    Throwable cause = e.getCause();
+    if (cause != null && !(cause instanceof NoMergeBaseException)) {
+      cause = cause.getCause();
+    }
+    if (cause instanceof NoMergeBaseException) {
+      throw new ResourceConflictException(
+          String.format("Cannot create auto merge commit: %s", e.getMessage()), e);
+    }
+    throw new PatchListNotAvailableException(e);
+  }
+
+  private Map<String, FileInfo> asFileInfo(Map<String, FileDiffOutput> fileDiffs) {
+    Map<String, FileInfo> result = new HashMap<>();
+    for (String path : fileDiffs.keySet()) {
+      FileDiffOutput fileDiff = fileDiffs.get(path);
+      FileInfo fileInfo = new FileInfo();
+      fileInfo.status =
+          fileDiff.changeType() != Patch.ChangeType.MODIFIED
+              ? fileDiff.changeType().getCode()
+              : null;
+      fileInfo.oldPath = fileDiff.oldPath().orElse(null);
+      fileInfo.sizeDelta = fileDiff.sizeDelta();
+      fileInfo.size = fileDiff.size();
+      if (fileDiff.patchType().get() == Patch.PatchType.BINARY) {
+        fileInfo.binary = true;
+      } else {
+        fileInfo.linesInserted = fileDiff.insertions() > 0 ? fileDiff.insertions() : null;
+        fileInfo.linesDeleted = fileDiff.deletions() > 0 ? fileDiff.deletions() : null;
+      }
+      result.put(path, fileInfo);
+    }
+    return result;
+  }
+}
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonOldImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonOldImpl.java
new file mode 100644
index 0000000..55d162a
--- /dev/null
+++ b/java/com/google/gerrit/server/change/FileInfoJsonOldImpl.java
@@ -0,0 +1,128 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListEntry;
+import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.errors.NoMergeBaseException;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Implementation of {@link FileInfoJson} using the old diff cache {@link PatchListCache}. */
+@Deprecated
+@Singleton
+class FileInfoJsonOldImpl implements FileInfoJson {
+  private final PatchListCache patchListCache;
+
+  @Inject
+  FileInfoJsonOldImpl(PatchListCache patchListCache) {
+    this.patchListCache = patchListCache;
+  }
+
+  @Override
+  public Map<String, FileInfo> getFileInfoMap(
+      Change change, ObjectId objectId, @Nullable PatchSet base)
+      throws ResourceConflictException, PatchListNotAvailableException {
+    ObjectId a = base != null ? base.commitId() : null;
+    return toFileInfoMap(change, PatchListKey.againstCommit(a, objectId, Whitespace.IGNORE_NONE));
+  }
+
+  @Override
+  public Map<String, FileInfo> getFileInfoMap(
+      Project.NameKey project, ObjectId objectId, int parentNum)
+      throws ResourceConflictException, PatchListNotAvailableException {
+    PatchListKey key =
+        parentNum == -1
+            ? PatchListKey.againstDefaultBase(objectId, Whitespace.IGNORE_NONE)
+            : PatchListKey.againstParentNum(
+                parentNum + 1, objectId, DiffPreferencesInfo.Whitespace.IGNORE_NONE);
+    return toFileInfoMap(project, key);
+  }
+
+  private Map<String, FileInfo> toFileInfoMap(Change change, PatchListKey key)
+      throws ResourceConflictException, PatchListNotAvailableException {
+    return toFileInfoMap(change.getProject(), key);
+  }
+
+  Map<String, FileInfo> toFileInfoMap(Project.NameKey project, PatchListKey key)
+      throws ResourceConflictException, PatchListNotAvailableException {
+    PatchList list;
+    try {
+      list = patchListCache.get(key, project);
+    } catch (PatchListNotAvailableException e) {
+      Throwable cause = e.getCause();
+      if (cause instanceof ExecutionException) {
+        cause = cause.getCause();
+      }
+      if (cause instanceof NoMergeBaseException) {
+        throw new ResourceConflictException(
+            String.format("Cannot create auto merge commit: %s", e.getMessage()), e);
+      }
+      throw e;
+    }
+
+    Map<String, FileInfo> files = new TreeMap<>();
+    for (PatchListEntry e : list.getPatches()) {
+      FileInfo fileInfo = new FileInfo();
+      fileInfo.status =
+          e.getChangeType() != Patch.ChangeType.MODIFIED ? e.getChangeType().getCode() : null;
+      fileInfo.oldPath = e.getOldName();
+      fileInfo.sizeDelta = e.getSizeDelta();
+      fileInfo.size = e.getSize();
+      if (e.getPatchType() == Patch.PatchType.BINARY) {
+        fileInfo.binary = true;
+      } else {
+        fileInfo.linesInserted = e.getInsertions() > 0 ? e.getInsertions() : null;
+        fileInfo.linesDeleted = e.getDeletions() > 0 ? e.getDeletions() : null;
+      }
+
+      FileInfo o = files.put(e.getNewName(), fileInfo);
+      if (o != null) {
+        // This should only happen on a delete-add break created by JGit
+        // when the file was rewritten and too little content survived. Write
+        // a single record with data from both sides.
+        fileInfo.status = Patch.ChangeType.REWRITE.getCode();
+        fileInfo.sizeDelta = o.sizeDelta;
+        fileInfo.size = o.size;
+        if (o.binary != null && o.binary) {
+          fileInfo.binary = true;
+        }
+        if (o.linesInserted != null) {
+          fileInfo.linesInserted = o.linesInserted;
+        }
+        if (o.linesDeleted != null) {
+          fileInfo.linesDeleted = o.linesDeleted;
+        }
+      }
+    }
+    return files;
+  }
+}
diff --git a/java/com/google/gerrit/server/change/LabelsJson.java b/java/com/google/gerrit/server/change/LabelsJson.java
index b1d154c..acff03c 100644
--- a/java/com/google/gerrit/server/change/LabelsJson.java
+++ b/java/com/google/gerrit/server/change/LabelsJson.java
@@ -20,14 +20,12 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.LinkedHashMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
-import com.google.common.collect.Sets;
 import com.google.common.collect.Table;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
@@ -38,21 +36,18 @@
 import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.SubmitRecord;
-import com.google.gerrit.extensions.client.ListChangesOption;
 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 +63,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);
   }
 
   /**
@@ -171,11 +153,6 @@
     return permitted.asMap();
   }
 
-  private static boolean containsAnyOf(
-      ImmutableSet<ListChangesOption> set, ImmutableSet<ListChangesOption> toFind) {
-    return !Sets.intersection(toFind, set).isEmpty();
-  }
-
   private static boolean isOnlyZero(Collection<String> values) {
     return values.isEmpty() || (values.size() == 1 && values.contains(" 0"));
   }
@@ -253,14 +230,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/PluginDefinedAttributesFactories.java b/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java
index b474dab..db21f11 100644
--- a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java
+++ b/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java
@@ -14,55 +14,22 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.common.collect.ImmutableList.toImmutableList;
 import static java.util.concurrent.TimeUnit.MINUTES;
 
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.server.DynamicOptions.BeanProvider;
 import com.google.gerrit.server.query.change.ChangeData;
 import java.util.Collection;
-import java.util.Objects;
 import java.util.stream.Stream;
 
-/** Static helpers for use by {@link PluginDefinedAttributesFactory} implementations. */
+/** Static helpers for use by {@link PluginDefinedInfosFactory} implementations. */
 public class PluginDefinedAttributesFactories {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  @Nullable
-  public static ImmutableList<PluginDefinedInfo> createAll(
-      ChangeData cd,
-      BeanProvider beanProvider,
-      Stream<Extension<ChangeAttributeFactory>> attrFactories) {
-    ImmutableList<PluginDefinedInfo> result =
-        attrFactories
-            .map(e -> tryCreate(cd, beanProvider, e.getPluginName(), e.get()))
-            .filter(Objects::nonNull)
-            .collect(toImmutableList());
-    return !result.isEmpty() ? result : null;
-  }
-
-  @Nullable
-  private static PluginDefinedInfo tryCreate(
-      ChangeData cd, BeanProvider beanProvider, String plugin, ChangeAttributeFactory attrFactory) {
-    PluginDefinedInfo pdi = null;
-    try {
-      pdi = attrFactory.create(cd, beanProvider, plugin);
-    } catch (RuntimeException ex) {
-      logger.atWarning().atMostEvery(1, MINUTES).withCause(ex).log(
-          "error populating attribute on change %s from plugin %s", cd.getId(), plugin);
-    }
-    if (pdi != null) {
-      pdi.name = plugin;
-    }
-    return pdi;
-  }
-
   public static ImmutableListMultimap<Change.Id, PluginDefinedInfo> createAll(
       Collection<ChangeData> cds,
       BeanProvider beanProvider,
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index 231359b..b43996e 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -15,8 +15,11 @@
 package com.google.gerrit.server.change;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
@@ -26,6 +29,8 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.change.RebaseUtil.Base;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -41,13 +46,27 @@
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.diff.Sequence;
+import org.eclipse.jgit.dircache.DirCache;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.merge.MergeResult;
+import org.eclipse.jgit.merge.ResolveMerger;
 import org.eclipse.jgit.merge.ThreeWayMerger;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
+/**
+ * BatchUpdate operation that rebases a change.
+ *
+ * <p>Can only be executed in a {@link com.google.gerrit.server.update.BatchUpdate} set has a {@link
+ * CodeReviewRevWalk} set as {@link RevWalk} (set via {@link
+ * com.google.gerrit.server.update.BatchUpdate#setRepository(org.eclipse.jgit.lib.Repository,
+ * RevWalk, org.eclipse.jgit.lib.ObjectInserter)}).
+ */
 public class RebaseChangeOp implements BatchUpdateOp {
   public interface Factory {
     RebaseChangeOp create(ChangeNotes notes, PatchSet originalPatchSet, ObjectId baseCommitId);
@@ -69,12 +88,13 @@
   private boolean validate = true;
   private boolean checkAddPatchSetPermission = true;
   private boolean forceContentMerge;
+  private boolean allowConflicts;
   private boolean detailedCommitMessage;
   private boolean postMessage = true;
   private boolean sendEmail = true;
   private boolean matchAuthorToCommitterDate = false;
 
-  private RevCommit rebasedCommit;
+  private CodeReviewCommit rebasedCommit;
   private PatchSet.Id rebasedPatchSetId;
   private PatchSetInserter patchSetInserter;
   private PatchSet rebasedPatchSet;
@@ -126,6 +146,19 @@
     return this;
   }
 
+  /**
+   * Allows the rebase to succeed if there are conflicts.
+   *
+   * <p>This setting requires that {@link #forceContentMerge} is set {@code true}. If {@link
+   * #forceContentMerge} is {@code false} this setting has no effect.
+   *
+   * @see #setForceContentMerge(boolean)
+   */
+  public RebaseChangeOp setAllowConflicts(boolean allowConflicts) {
+    this.allowConflicts = allowConflicts;
+    return this;
+  }
+
   public RebaseChangeOp setDetailedCommitMessage(boolean detailedCommitMessage) {
     this.detailedCommitMessage = detailedCommitMessage;
     return this;
@@ -187,13 +220,15 @@
             .setCheckAddPatchSetPermission(checkAddPatchSetPermission)
             .setValidate(validate)
             .setSendEmail(sendEmail);
+
+    if (!rebasedCommit.getFilesWithGitConflicts().isEmpty()
+        && !notes.getChange().isWorkInProgress()) {
+      patchSetInserter.setWorkInProgress(true);
+    }
+
     if (postMessage) {
       patchSetInserter.setMessage(
-          "Patch Set "
-              + rebasedPatchSetId.get()
-              + ": Patch Set "
-              + originalPatchSet.id().get()
-              + " was rebased");
+          messageForRebasedChange(rebasedPatchSetId, originalPatchSet.id(), rebasedCommit));
     }
 
     if (base != null && !base.notes().getChange().isMerged()) {
@@ -208,6 +243,24 @@
     patchSetInserter.updateRepo(ctx);
   }
 
+  private static String messageForRebasedChange(
+      PatchSet.Id rebasePatchSetId, PatchSet.Id originalPatchSetId, CodeReviewCommit commit) {
+    StringBuilder stringBuilder =
+        new StringBuilder(
+            String.format(
+                "Patch Set %d: Patch Set %d was rebased",
+                rebasePatchSetId.get(), originalPatchSetId.get()));
+
+    if (!commit.getFilesWithGitConflicts().isEmpty()) {
+      stringBuilder.append("\n\nThe following files contain Git conflicts:\n");
+      commit.getFilesWithGitConflicts().stream()
+          .sorted()
+          .forEach(filePath -> stringBuilder.append("* ").append(filePath).append("\n"));
+    }
+
+    return stringBuilder.toString();
+  }
+
   @Override
   public boolean updateChange(ChangeContext ctx)
       throws ResourceConflictException, IOException, BadRequestException {
@@ -221,7 +274,7 @@
     patchSetInserter.postUpdate(ctx);
   }
 
-  public RevCommit getRebasedCommit() {
+  public CodeReviewCommit getRebasedCommit() {
     checkState(rebasedCommit != null, "getRebasedCommit() only valid after updateRepo");
     return rebasedCommit;
   }
@@ -254,7 +307,7 @@
    * @throws MergeConflictException the rebase failed due to a merge conflict.
    * @throws IOException the merge failed for another reason.
    */
-  private RevCommit rebaseCommit(
+  private CodeReviewCommit rebaseCommit(
       RepoContext ctx, RevCommit original, ObjectId base, String commitMessage)
       throws ResourceConflictException, IOException {
     RevCommit parentCommit = original.getParent(0);
@@ -266,15 +319,56 @@
     ThreeWayMerger merger =
         newMergeUtil().newThreeWayMerger(ctx.getInserter(), ctx.getRepoView().getConfig());
     merger.setBase(parentCommit);
+
+    DirCache dc = DirCache.newInCore();
+    if (allowConflicts && merger instanceof ResolveMerger) {
+      // The DirCache must be set on ResolveMerger before calling
+      // ResolveMerger#merge(AnyObjectId...) otherwise the entries in DirCache don't get populated.
+      ((ResolveMerger) merger).setDirCache(dc);
+    }
+
     boolean success = merger.merge(original, base);
 
-    if (!success || merger.getResultTreeId() == null) {
-      throw new MergeConflictException(
-          "The change could not be rebased due to a conflict during merge.");
+    ObjectId tree;
+    ImmutableSet<String> filesWithGitConflicts;
+    if (success) {
+      filesWithGitConflicts = null;
+      tree = merger.getResultTreeId();
+    } else {
+      List<String> conflicts = ImmutableList.of();
+      if (merger instanceof ResolveMerger) {
+        conflicts = ((ResolveMerger) merger).getUnmergedPaths();
+      }
+
+      if (!allowConflicts || !(merger instanceof ResolveMerger)) {
+        throw new MergeConflictException(
+            "The change could not be rebased due to a conflict during merge.\n\n"
+                + MergeUtil.createConflictMessage(conflicts));
+      }
+
+      Map<String, MergeResult<? extends Sequence>> mergeResults =
+          ((ResolveMerger) merger).getMergeResults();
+
+      filesWithGitConflicts =
+          mergeResults.entrySet().stream()
+              .filter(e -> e.getValue().containsConflicts())
+              .map(Map.Entry::getKey)
+              .collect(toImmutableSet());
+
+      tree =
+          MergeUtil.mergeWithConflicts(
+              ctx.getRevWalk(),
+              ctx.getInserter(),
+              dc,
+              "PATCH SET",
+              original,
+              "BASE",
+              ctx.getRevWalk().parseCommit(base),
+              mergeResults);
     }
 
     CommitBuilder cb = new CommitBuilder();
-    cb.setTreeId(merger.getResultTreeId());
+    cb.setTreeId(tree);
     cb.setParentId(base);
     cb.setAuthor(original.getAuthorIdent());
     cb.setMessage(commitMessage);
@@ -290,6 +384,8 @@
     }
     ObjectId objectId = ctx.getInserter().insert(cb);
     ctx.getInserter().flush();
-    return ctx.getRevWalk().parseCommit(objectId);
+    CodeReviewCommit commit = ((CodeReviewRevWalk) ctx.getRevWalk()).parseCommit(objectId);
+    commit.setFilesWithGitConflicts(filesWithGitConflicts);
+    return commit;
   }
 }
diff --git a/java/com/google/gerrit/server/change/ResetCherryPickOp.java b/java/com/google/gerrit/server/change/ResetCherryPickOp.java
new file mode 100644
index 0000000..d1177d4
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ResetCherryPickOp.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+
+/** Reset cherryPickOf to an empty value. */
+public class ResetCherryPickOp implements BatchUpdateOp {
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws RestApiException {
+    Change change = ctx.getChange();
+    if (change.getCherryPickOf() == null) {
+      return false;
+    }
+
+    ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
+    update.resetCherryPickOf();
+    return true;
+  }
+}
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/change/ReviewerJson.java b/java/com/google/gerrit/server/change/ReviewerJson.java
index a3136d4a..761b57d 100644
--- a/java/com/google/gerrit/server/change/ReviewerJson.java
+++ b/java/com/google/gerrit/server/change/ReviewerJson.java
@@ -32,7 +32,6 @@
 import com.google.gerrit.server.permissions.LabelPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
@@ -47,20 +46,17 @@
   private final ChangeData.Factory changeDataFactory;
   private final ApprovalsUtil approvalsUtil;
   private final AccountLoader.Factory accountLoaderFactory;
-  private final SubmitRuleEvaluator submitRuleEvaluator;
 
   @Inject
   ReviewerJson(
       PermissionBackend permissionBackend,
       ChangeData.Factory changeDataFactory,
       ApprovalsUtil approvalsUtil,
-      AccountLoader.Factory accountLoaderFactory,
-      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
+      AccountLoader.Factory accountLoaderFactory) {
     this.permissionBackend = permissionBackend;
     this.changeDataFactory = changeDataFactory;
     this.approvalsUtil = approvalsUtil;
     this.accountLoaderFactory = accountLoaderFactory;
-    submitRuleEvaluator = submitRuleEvaluatorFactory.create(SubmitRuleOptions.defaults());
   }
 
   public List<ReviewerInfo> format(Collection<ReviewerResource> rsrcs)
@@ -123,7 +119,7 @@
     if (ps != null) {
       PermissionBackend.ForChange perm = permissionBackend.absentUser(reviewerAccountId).change(cd);
 
-      for (SubmitRecord rec : submitRuleEvaluator.evaluate(cd)) {
+      for (SubmitRecord rec : cd.submitRecords(SubmitRuleOptions.defaults())) {
         if (rec.labels == null) {
           continue;
         }
diff --git a/java/com/google/gerrit/server/change/RevisionJson.java b/java/com/google/gerrit/server/change/RevisionJson.java
index 414107f..558bdba 100644
--- a/java/com/google/gerrit/server/change/RevisionJson.java
+++ b/java/com/google/gerrit/server/change/RevisionJson.java
@@ -164,7 +164,12 @@
    * RevWalk and assumes it is backed by an open repository.
    */
   public CommitInfo getCommitInfo(
-      Project.NameKey project, RevWalk rw, RevCommit commit, boolean addLinks, boolean fillCommit)
+      Project.NameKey project,
+      RevWalk rw,
+      RevCommit commit,
+      boolean addLinks,
+      boolean fillCommit,
+      String branchName)
       throws IOException {
     CommitInfo info = new CommitInfo();
     if (fillCommit) {
@@ -177,7 +182,8 @@
     info.message = commit.getFullMessage();
 
     if (addLinks) {
-      ImmutableList<WebLinkInfo> links = webLinks.getPatchSetLinks(project, commit.name());
+      ImmutableList<WebLinkInfo> links =
+          webLinks.getPatchSetLinks(project, commit.name(), commit.getFullMessage(), branchName);
       info.webLinks = links.isEmpty() ? null : links;
     }
 
@@ -187,7 +193,8 @@
       i.commit = parent.name();
       i.subject = parent.getShortMessage();
       if (addLinks) {
-        ImmutableList<WebLinkInfo> parentLinks = webLinks.getParentLinks(project, parent.name());
+        ImmutableList<WebLinkInfo> parentLinks =
+            webLinks.getParentLinks(project, parent.name(), parent.getFullMessage(), branchName);
         i.webLinks = parentLinks.isEmpty() ? null : parentLinks;
       }
       info.parents.add(i);
@@ -288,11 +295,12 @@
       String rev = in.commitId().name();
       RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
       rw.parseBody(commit);
+      String branchName = cd.change().getDest().branch();
       if (setCommit) {
-        out.commit = getCommitInfo(project, rw, commit, has(WEB_LINKS), fillCommit);
+        out.commit = getCommitInfo(project, rw, commit, has(WEB_LINKS), fillCommit, branchName);
       }
       if (addFooters) {
-        Ref ref = repo.exactRef(cd.change().getDest().branch());
+        Ref ref = repo.exactRef(branchName);
         RevCommit mergeTip = null;
         if (ref != null) {
           mergeTip = rw.parseCommit(ref.getObjectId());
@@ -307,7 +315,7 @@
 
     if (has(ALL_FILES) || (out.isCurrent && has(CURRENT_FILES))) {
       try {
-        out.files = fileInfoJson.toFileInfoMap(c, in);
+        out.files = fileInfoJson.getFileInfoMap(c, in);
         out.files.remove(Patch.COMMIT_MSG);
         out.files.remove(Patch.MERGE_LIST);
       } catch (ResourceConflictException e) {
diff --git a/java/com/google/gerrit/server/restapi/change/SetTopicOp.java b/java/com/google/gerrit/server/change/SetTopicOp.java
similarity index 88%
rename from java/com/google/gerrit/server/restapi/change/SetTopicOp.java
rename to java/com/google/gerrit/server/change/SetTopicOp.java
index 9eff5c1..c4a49b0 100644
--- a/java/com/google/gerrit/server/restapi/change/SetTopicOp.java
+++ b/java/com/google/gerrit/server/change/SetTopicOp.java
@@ -12,12 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.restapi.change;
+package com.google.gerrit.server.change;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.extensions.api.changes.TopicInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.extensions.events.TopicEdited;
@@ -31,10 +31,10 @@
 
 public class SetTopicOp implements BatchUpdateOp {
   public interface Factory {
-    SetTopicOp create(TopicInput input);
+    SetTopicOp create(@Nullable String topic);
   }
 
-  private final TopicInput input;
+  private final String topic;
   private final TopicEdited topicEdited;
   private final ChangeMessagesUtil cmUtil;
 
@@ -44,8 +44,8 @@
 
   @Inject
   public SetTopicOp(
-      TopicEdited topicEdited, ChangeMessagesUtil cmUtil, @Assisted TopicInput input) {
-    this.input = input;
+      TopicEdited topicEdited, ChangeMessagesUtil cmUtil, @Nullable @Assisted String topic) {
+    this.topic = topic;
     this.topicEdited = topicEdited;
     this.cmUtil = cmUtil;
   }
@@ -54,7 +54,7 @@
   public boolean updateChange(ChangeContext ctx) throws BadRequestException {
     change = ctx.getChange();
     ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
-    newTopicName = Strings.nullToEmpty(input.topic);
+    newTopicName = Strings.nullToEmpty(topic);
     oldTopicName = Strings.nullToEmpty(change.getTopic());
     if (oldTopicName.equals(newTopicName)) {
       return false;
diff --git a/java/com/google/gerrit/server/comment/CommentContextCache.java b/java/com/google/gerrit/server/comment/CommentContextCache.java
new file mode 100644
index 0000000..8c40763
--- /dev/null
+++ b/java/com/google/gerrit/server/comment/CommentContextCache.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License
+
+package com.google.gerrit.server.comment;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.CommentContext;
+
+/**
+ * Caches the context lines of comments (source file content surrounding and including the lines
+ * where the comment was written)
+ */
+public interface CommentContextCache {
+
+  /**
+   * Returns the context lines for a single comment.
+   *
+   * @param key a key representing a subset of fields for a comment that serves as an identifier.
+   * @return a {@link CommentContext} object containing all line numbers and text of the context.
+   */
+  CommentContext get(CommentContextKey key);
+
+  /**
+   * Returns the context lines for multiple comments - identified by their {@code keys}.
+   *
+   * @param keys list of keys, where each key represents a single comment through its project,
+   *     change ID, patchset, path and ID. The keys can belong to different projects and changes.
+   * @return {@code Map} of {@code CommentContext} containing the context for all comments.
+   */
+  ImmutableMap<CommentContextKey, CommentContext> getAll(Iterable<CommentContextKey> keys);
+}
diff --git a/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java b/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
new file mode 100644
index 0000000..3d75349
--- /dev/null
+++ b/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
@@ -0,0 +1,307 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License
+
+package com.google.gerrit.server.comment;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.cache.Weigher;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Streams;
+import com.google.common.flogger.FluentLogger;
+import com.google.common.hash.Hashing;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.CommentContext;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.proto.Cache.AllCommentContextProto;
+import com.google.gerrit.server.cache.proto.Cache.AllCommentContextProto.CommentContextProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.comment.CommentContextLoader.ContextInput;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/** Implementation of {@link CommentContextCache}. */
+public class CommentContextCacheImpl implements CommentContextCache {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private static final String CACHE_NAME = "comment_context";
+
+  /**
+   * Comment context is expected to contain just few lines of code to be displayed beside the
+   * comment. Setting an upper bound of 100 for padding.
+   */
+  @VisibleForTesting public static final int MAX_CONTEXT_PADDING = 50;
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        persist(CACHE_NAME, CommentContextKey.class, CommentContext.class)
+            .version(2)
+            .diskLimit(1 << 30) // limit the total cache size to 1 GB
+            .maximumWeight(1 << 23) // Limit the size of the in-memory cache to 8 MB
+            .weigher(CommentContextWeigher.class)
+            .keySerializer(CommentContextKey.Serializer.INSTANCE)
+            .valueSerializer(CommentContextSerializer.INSTANCE)
+            .loader(Loader.class);
+
+        bind(CommentContextCache.class).to(CommentContextCacheImpl.class);
+      }
+    };
+  }
+
+  private final LoadingCache<CommentContextKey, CommentContext> contextCache;
+
+  @Inject
+  CommentContextCacheImpl(
+      @Named(CACHE_NAME) LoadingCache<CommentContextKey, CommentContext> contextCache) {
+    this.contextCache = contextCache;
+  }
+
+  @Override
+  public CommentContext get(CommentContextKey comment) {
+    return getAll(ImmutableList.of(comment)).get(comment);
+  }
+
+  @Override
+  public ImmutableMap<CommentContextKey, CommentContext> getAll(
+      Iterable<CommentContextKey> inputKeys) {
+    ImmutableMap.Builder<CommentContextKey, CommentContext> result = ImmutableMap.builder();
+
+    List<CommentContextKey> adjustedKeys =
+        Streams.stream(inputKeys)
+            .map(CommentContextCacheImpl::adjustMaxContextPadding)
+            .collect(ImmutableList.toImmutableList());
+
+    // Convert the input keys to the same keys but with their file paths hashed
+    Map<CommentContextKey, CommentContextKey> keysToCacheKeys =
+        adjustedKeys.stream()
+            .collect(
+                Collectors.toMap(
+                    Function.identity(),
+                    k -> k.toBuilder().path(Loader.hashPath(k.path())).build()));
+
+    try {
+      ImmutableMap<CommentContextKey, CommentContext> allContext =
+          contextCache.getAll(keysToCacheKeys.values());
+
+      for (CommentContextKey inputKey : inputKeys) {
+        CommentContextKey cacheKey = keysToCacheKeys.get(adjustMaxContextPadding(inputKey));
+        result.put(inputKey, allContext.get(cacheKey));
+      }
+      return result.build();
+    } catch (ExecutionException e) {
+      throw new StorageException("Failed to retrieve comments' context", e);
+    }
+  }
+
+  private static CommentContextKey adjustMaxContextPadding(CommentContextKey key) {
+    if (key.contextPadding() < 0) {
+      logger.atWarning().log(
+          "Cannot set context padding to a negative number %d. Adjusting the number to 0",
+          key.contextPadding());
+      return key.toBuilder().contextPadding(0).build();
+    }
+    if (key.contextPadding() > MAX_CONTEXT_PADDING) {
+      logger.atWarning().log(
+          "Number of requested context lines is %d and exceeding the configured maximum of %d."
+              + " Adjusting the number to the maximum.",
+          key.contextPadding(), MAX_CONTEXT_PADDING);
+      return key.toBuilder().contextPadding(MAX_CONTEXT_PADDING).build();
+    }
+    return key;
+  }
+
+  public enum CommentContextSerializer implements CacheSerializer<CommentContext> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(CommentContext commentContext) {
+      AllCommentContextProto.Builder allBuilder = AllCommentContextProto.newBuilder();
+
+      commentContext
+          .lines()
+          .entrySet()
+          .forEach(
+              c ->
+                  allBuilder.addContext(
+                      CommentContextProto.newBuilder()
+                          .setLineNumber(c.getKey())
+                          .setContextLine(c.getValue())));
+      return Protos.toByteArray(allBuilder.build());
+    }
+
+    @Override
+    public CommentContext deserialize(byte[] in) {
+      ImmutableMap.Builder<Integer, String> contextLinesMap = ImmutableMap.builder();
+      Protos.parseUnchecked(AllCommentContextProto.parser(), in).getContextList().stream()
+          .forEach(c -> contextLinesMap.put(c.getLineNumber(), c.getContextLine()));
+      return CommentContext.create(contextLinesMap.build());
+    }
+  }
+
+  static class Loader extends CacheLoader<CommentContextKey, CommentContext> {
+    private final ChangeNotes.Factory notesFactory;
+    private final CommentsUtil commentsUtil;
+    private final CommentContextLoader.Factory factory;
+
+    @Inject
+    Loader(
+        CommentsUtil commentsUtil,
+        ChangeNotes.Factory notesFactory,
+        CommentContextLoader.Factory factory) {
+      this.commentsUtil = commentsUtil;
+      this.notesFactory = notesFactory;
+      this.factory = factory;
+    }
+
+    /**
+     * Load the comment context of a single comment identified by its key.
+     *
+     * @param key a {@link CommentContextKey} identifying a comment.
+     * @return the comment context associated with the comment.
+     * @throws IOException an error happened while parsing the commit or loading the file where the
+     *     comment is written.
+     */
+    @Override
+    public CommentContext load(CommentContextKey key) throws IOException {
+      return loadAll(ImmutableList.of(key)).get(key);
+    }
+
+    /**
+     * Load the comment context of different comments identified by their keys.
+     *
+     * @param keys list of {@link CommentContextKey} identifying some comments.
+     * @return a map of the input keys to their corresponding comment context.
+     * @throws IOException an error happened while parsing the commits or loading the files where
+     *     the comments are written.
+     */
+    @Override
+    public Map<CommentContextKey, CommentContext> loadAll(
+        Iterable<? extends CommentContextKey> keys) throws IOException {
+      ImmutableMap.Builder<CommentContextKey, CommentContext> result =
+          ImmutableMap.builderWithExpectedSize(Iterables.size(keys));
+
+      Map<Project.NameKey, Map<Change.Id, List<CommentContextKey>>> groupedKeys =
+          Streams.stream(keys)
+              .distinct()
+              .map(k -> (CommentContextKey) k)
+              .collect(
+                  Collectors.groupingBy(
+                      CommentContextKey::project,
+                      Collectors.groupingBy(CommentContextKey::changeId)));
+
+      for (Map.Entry<Project.NameKey, Map<Change.Id, List<CommentContextKey>>> perProject :
+          groupedKeys.entrySet()) {
+        Map<Change.Id, List<CommentContextKey>> keysPerProject = perProject.getValue();
+
+        for (Map.Entry<Change.Id, List<CommentContextKey>> perChange : keysPerProject.entrySet()) {
+          Map<CommentContextKey, CommentContext> context =
+              loadForSameChange(perChange.getValue(), perProject.getKey(), perChange.getKey());
+          result.putAll(context);
+        }
+      }
+      return result.build();
+    }
+
+    /**
+     * Load the comment context for comments of the same project and change ID.
+     *
+     * @param keys a list of keys corresponding to some comments
+     * @param project a gerrit project/repository
+     * @param changeId an identifier for a change
+     * @return a map of the input keys to their corresponding {@link CommentContext}
+     */
+    private Map<CommentContextKey, CommentContext> loadForSameChange(
+        List<CommentContextKey> keys, Project.NameKey project, Change.Id changeId)
+        throws IOException {
+      ChangeNotes notes = notesFactory.createChecked(project, changeId);
+      List<HumanComment> humanComments = commentsUtil.publishedHumanCommentsByChange(notes);
+      CommentContextLoader loader = factory.create(project);
+      Map<ContextInput, CommentContextKey> commentsToKeys = new HashMap<>();
+      for (CommentContextKey key : keys) {
+        Comment comment = getCommentForKey(humanComments, key);
+        commentsToKeys.put(ContextInput.fromComment(comment, key.contextPadding()), key);
+      }
+      Map<ContextInput, CommentContext> allContext = loader.getContext(commentsToKeys.keySet());
+      return allContext.entrySet().stream()
+          .collect(Collectors.toMap(e -> commentsToKeys.get(e.getKey()), Map.Entry::getValue));
+    }
+
+    /**
+     * Return the single comment from the {@code allComments} input list corresponding to the key
+     * parameter.
+     *
+     * @param allComments a list of comments.
+     * @param key a key representing a single comment.
+     * @return the single comment corresponding to the key parameter.
+     */
+    private Comment getCommentForKey(List<HumanComment> allComments, CommentContextKey key) {
+      return allComments.stream()
+          .filter(
+              c ->
+                  key.id().equals(c.key.uuid)
+                      && key.patchset() == c.key.patchSetId
+                      && key.path().equals(hashPath(c.key.filename)))
+          .findFirst()
+          .orElseThrow(() -> new IllegalArgumentException("Unable to find comment for key " + key));
+    }
+
+    /**
+     * Hash an input String using the general {@link Hashing#murmur3_128()} hash.
+     *
+     * @param input the input String
+     * @return a hashed representation of the input String
+     */
+    static String hashPath(String input) {
+      return Hashing.murmur3_128().hashString(input, UTF_8).toString();
+    }
+  }
+
+  private static class CommentContextWeigher implements Weigher<CommentContextKey, CommentContext> {
+    @Override
+    public int weigh(CommentContextKey key, CommentContext commentContext) {
+      int size = 0;
+      size += key.id().length();
+      size += key.path().length();
+      size += key.project().get().length();
+      size += 4;
+      for (String line : commentContext.lines().values()) {
+        size += 4; // line number
+        size += line.length(); // number of characters in the context line
+      }
+      return size;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/comment/CommentContextKey.java b/java/com/google/gerrit/server/comment/CommentContextKey.java
new file mode 100644
index 0000000..af2ae92
--- /dev/null
+++ b/java/com/google/gerrit/server/comment/CommentContextKey.java
@@ -0,0 +1,88 @@
+package com.google.gerrit.server.comment;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+
+/**
+ * An identifier of a comment that should be used to load the comment context using {@link
+ * CommentContextCache#get(CommentContextKey)}, or {@link CommentContextCache#getAll(Iterable)}.
+ *
+ * <p>The {@link CommentContextCacheImpl} implementation uses this class as the cache key, while
+ * replacing the {@link #path()} field with the hashed path.
+ */
+@AutoValue
+public abstract class CommentContextKey {
+  abstract Project.NameKey project();
+
+  abstract Change.Id changeId();
+
+  /** The unique comment ID. */
+  abstract String id();
+
+  /** File path at which the comment was written. */
+  abstract String path();
+
+  abstract Integer patchset();
+
+  /** Number of extra lines of context that should be added before and after the comment range. */
+  abstract int contextPadding();
+
+  abstract Builder toBuilder();
+
+  public static Builder builder() {
+    return new AutoValue_CommentContextKey.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder project(Project.NameKey nameKey);
+
+    public abstract Builder changeId(Change.Id changeId);
+
+    public abstract Builder id(String id);
+
+    public abstract Builder path(String path);
+
+    public abstract Builder patchset(Integer patchset);
+
+    public abstract Builder contextPadding(Integer numLines);
+
+    public abstract CommentContextKey build();
+  }
+
+  public enum Serializer implements CacheSerializer<CommentContextKey> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(CommentContextKey key) {
+      return Protos.toByteArray(
+          Cache.CommentContextKeyProto.newBuilder()
+              .setProject(key.project().get())
+              .setChangeId(key.changeId().toString())
+              .setPatchset(key.patchset())
+              .setPathHash(key.path())
+              .setCommentId(key.id())
+              .setContextPadding(key.contextPadding())
+              .build());
+    }
+
+    @Override
+    public CommentContextKey deserialize(byte[] in) {
+      Cache.CommentContextKeyProto proto =
+          Protos.parseUnchecked(Cache.CommentContextKeyProto.parser(), in);
+      return CommentContextKey.builder()
+          .project(Project.NameKey.parse(proto.getProject()))
+          .changeId(Change.Id.tryParse(proto.getChangeId()).get())
+          .patchset(proto.getPatchset())
+          .id(proto.getCommentId())
+          .path(proto.getPathHash())
+          .contextPadding(proto.getContextPadding())
+          .build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/comment/CommentContextLoader.java b/java/com/google/gerrit/server/comment/CommentContextLoader.java
new file mode 100644
index 0000000..c93f4b1
--- /dev/null
+++ b/java/com/google/gerrit/server/comment/CommentContextLoader.java
@@ -0,0 +1,262 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.comment;
+
+import static com.google.gerrit.entities.Patch.COMMIT_MSG;
+import static com.google.gerrit.entities.Patch.MERGE_LIST;
+import static java.util.stream.Collectors.groupingBy;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.CommentContext;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.common.ContextLineInfo;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.ComparisonType;
+import com.google.gerrit.server.patch.Text;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+
+/**
+ * Computes the list of {@link ContextLineInfo} for a given comment, that is, the lines of the
+ * source file surrounding and including the area where the comment was written.
+ */
+public class CommentContextLoader {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final GitRepositoryManager repoManager;
+  private final Project.NameKey project;
+
+  public interface Factory {
+    CommentContextLoader create(Project.NameKey project);
+  }
+
+  @Inject
+  CommentContextLoader(GitRepositoryManager repoManager, @Assisted Project.NameKey project) {
+    this.repoManager = repoManager;
+    this.project = project;
+  }
+
+  /**
+   * Load the comment context for multiple contextInputs at once. This method will open the
+   * repository and read the source files for all necessary contextInputs' file paths.
+   *
+   * @param contextInputs a list of contextInputs.
+   * @return a Map where all entries consist of the input contextInputs and the values are their
+   *     corresponding {@link CommentContext}.
+   */
+  public Map<ContextInput, CommentContext> getContext(Collection<ContextInput> contextInputs)
+      throws IOException {
+    ImmutableMap.Builder<ContextInput, CommentContext> result =
+        ImmutableMap.builderWithExpectedSize(Iterables.size(contextInputs));
+
+    // Group contextInputs by commit ID so that each commit is parsed only once
+    Map<ObjectId, List<ContextInput>> commentsByCommitId =
+        contextInputs.stream().collect(groupingBy(ContextInput::commitId));
+
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      for (ObjectId commitId : commentsByCommitId.keySet()) {
+        RevCommit commit = rw.parseCommit(commitId);
+        for (ContextInput contextInput : commentsByCommitId.get(commitId)) {
+          Optional<Range> range = getStartAndEndLines(contextInput);
+          if (!range.isPresent()) {
+            result.put(contextInput, CommentContext.empty());
+            continue;
+          }
+          String filePath = contextInput.filePath();
+          switch (filePath) {
+            case COMMIT_MSG:
+              result.put(
+                  contextInput,
+                  getContextForCommitMessage(
+                      rw.getObjectReader(), commit, range.get(), contextInput.contextPadding()));
+              break;
+            case MERGE_LIST:
+              result.put(
+                  contextInput,
+                  getContextForMergeList(
+                      rw.getObjectReader(), commit, range.get(), contextInput.contextPadding()));
+              break;
+            default:
+              result.put(
+                  contextInput,
+                  getContextForFilePath(
+                      repo, rw, commit, filePath, range.get(), contextInput.contextPadding()));
+          }
+        }
+      }
+      return result.build();
+    }
+  }
+
+  private CommentContext getContextForCommitMessage(
+      ObjectReader reader, RevCommit commit, Range commentRange, int contextPadding)
+      throws IOException {
+    Text text = Text.forCommit(reader, commit);
+    return createContext(text, commentRange, contextPadding);
+  }
+
+  private CommentContext getContextForMergeList(
+      ObjectReader reader, RevCommit commit, Range commentRange, int contextPadding)
+      throws IOException {
+    ComparisonType cmp = ComparisonType.againstParent(1);
+    Text text = Text.forMergeList(cmp, reader, commit);
+    return createContext(text, commentRange, contextPadding);
+  }
+
+  private CommentContext getContextForFilePath(
+      Repository repo,
+      RevWalk rw,
+      RevCommit commit,
+      String filePath,
+      Range commentRange,
+      int contextPadding)
+      throws IOException {
+    // TODO(ghareeb): We can further group the comments by file paths to avoid opening
+    // the same file multiple times.
+    try (TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), filePath, commit.getTree())) {
+      if (tw == null) {
+        logger.atWarning().log(
+            "Could not find path %s in the git tree of ID %s.", filePath, commit.getTree().getId());
+        return CommentContext.empty();
+      }
+      ObjectId id = tw.getObjectId(0);
+      Text src = new Text(repo.open(id, Constants.OBJ_BLOB));
+      return createContext(src, commentRange, contextPadding);
+    }
+  }
+
+  private static CommentContext createContext(Text src, Range commentRange, int contextPadding) {
+    if (commentRange.start() < 1 || commentRange.end() - 1 > src.size()) {
+      // TODO(ghareeb): We should throw an exception in this case. See
+      // https://bugs.chromium.org/p/gerrit/issues/detail?id=14102 which is an example where the
+      // diff contains an extra line not in the original file.
+      return CommentContext.empty();
+    }
+    commentRange = adjustRange(commentRange, contextPadding, src.size());
+    ImmutableMap.Builder<Integer, String> context =
+        ImmutableMap.builderWithExpectedSize(commentRange.end() - commentRange.start());
+    for (int i = commentRange.start(); i < commentRange.end(); i++) {
+      context.put(i, src.getString(i - 1));
+    }
+    return CommentContext.create(context.build());
+  }
+
+  /**
+   * Adjust the {@code commentRange} parameter by adding {@code contextPadding} lines before and
+   * after the comment range.
+   */
+  private static Range adjustRange(Range commentRange, int contextPadding, int fileLines) {
+    int newStartLine = commentRange.start() - contextPadding;
+    int newEndLine = commentRange.end() + contextPadding;
+    return Range.create(Math.max(1, newStartLine), Math.min(fileLines + 1, newEndLine));
+  }
+
+  private static Optional<Range> getStartAndEndLines(ContextInput comment) {
+    if (comment.range() != null) {
+      return Optional.of(Range.create(comment.range().startLine, comment.range().endLine + 1));
+    } else if (comment.lineNumber() > 0) {
+      return Optional.of(Range.create(comment.lineNumber(), comment.lineNumber() + 1));
+    }
+    return Optional.empty();
+  }
+
+  @AutoValue
+  abstract static class Range {
+    static Range create(int start, int end) {
+      return new AutoValue_CommentContextLoader_Range(start, end);
+    }
+
+    /** Start line of the comment (inclusive). */
+    abstract int start();
+
+    /** End line of the comment (exclusive). */
+    abstract int end();
+
+    /** Number of lines covered by this range. */
+    int size() {
+      return end() - start();
+    }
+  }
+
+  /** This entity only contains comment fields needed to load the comment context. */
+  @AutoValue
+  abstract static class ContextInput {
+    static ContextInput fromComment(Comment comment, int contextPadding) {
+      return new AutoValue_CommentContextLoader_ContextInput.Builder()
+          .commitId(comment.getCommitId())
+          .filePath(comment.key.filename)
+          .range(comment.range)
+          .lineNumber(comment.lineNbr)
+          .contextPadding(contextPadding)
+          .build();
+    }
+
+    /** 20 bytes SHA-1 of the patchset commit containing the file where the comment is written. */
+    abstract ObjectId commitId();
+
+    /** File path where the comment is written. */
+    abstract String filePath();
+
+    /**
+     * Position of the comment in the file (start line, start char, end line, end char). This field
+     * can be null if the range is not available for this comment.
+     */
+    @Nullable
+    abstract Comment.Range range();
+
+    /**
+     * The 1-based line number where the comment is written. A value 0 means that the line number is
+     * not available for this comment.
+     */
+    abstract Integer lineNumber();
+
+    /** Number of extra lines of context that should be added before and after the comment range. */
+    abstract Integer contextPadding();
+
+    @AutoValue.Builder
+    public abstract static class Builder {
+
+      public abstract Builder commitId(ObjectId commitId);
+
+      public abstract Builder filePath(String filePath);
+
+      public abstract Builder range(@Nullable Comment.Range range);
+
+      public abstract Builder lineNumber(Integer lineNumber);
+
+      public abstract Builder contextPadding(Integer contextPadding);
+
+      public abstract ContextInput build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/config/ConfigUtil.java b/java/com/google/gerrit/server/config/ConfigUtil.java
index 43c05e0..27ded63 100644
--- a/java/com/google/gerrit/server/config/ConfigUtil.java
+++ b/java/com/google/gerrit/server/config/ConfigUtil.java
@@ -16,6 +16,7 @@
 
 import static java.util.Objects.requireNonNull;
 
+import com.google.common.flogger.FluentLogger;
 import java.lang.reflect.Field;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Modifier;
@@ -30,6 +31,8 @@
 import org.eclipse.jgit.lib.Config;
 
 public class ConfigUtil {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   @SuppressWarnings("unchecked")
   private static <T> T[] allValuesOf(T defaultValue) {
     try {
@@ -138,7 +141,12 @@
     } else {
       for (String string : values) {
         if (string != null) {
-          list.add(getEnum(section, subsection, setting, string, all));
+          try {
+            list.add(getEnum(section, subsection, setting, string, all));
+          } catch (IllegalArgumentException ex) {
+            // It's better to ignore a wrongly configured enum, rather than fail to load Gerrit.
+            logger.atWarning().log(ex.getMessage());
+          }
         }
       }
     }
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 78dd38c..52de9d5 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -81,6 +81,7 @@
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.ExceptionHookImpl;
+import com.google.gerrit.server.ExternalUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.RequestListener;
 import com.google.gerrit.server.TraceRequestListener;
@@ -100,21 +101,20 @@
 import com.google.gerrit.server.account.externalids.ExternalIdModule;
 import com.google.gerrit.server.auth.AuthBackend;
 import com.google.gerrit.server.auth.UniversalAuthBackend;
-import com.google.gerrit.server.auth.oauth.OAuthTokenCache;
 import com.google.gerrit.server.avatar.AvatarProvider;
 import com.google.gerrit.server.cache.CacheRemovalListener;
 import com.google.gerrit.server.change.AbandonOp;
 import com.google.gerrit.server.change.AccountPatchReviewStore;
-import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.gerrit.server.change.ChangeETagComputation;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
 import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
-import com.google.gerrit.server.change.LabelsJson;
+import com.google.gerrit.server.change.FileInfoJsonModule;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
 import com.google.gerrit.server.change.ReviewerSuggestion;
 import com.google.gerrit.server.change.RevisionJson;
+import com.google.gerrit.server.comment.CommentContextCacheImpl;
 import com.google.gerrit.server.events.EventFactory;
 import com.google.gerrit.server.events.EventListener;
 import com.google.gerrit.server.events.EventsMetrics;
@@ -162,6 +162,7 @@
 import com.google.gerrit.server.mime.FileTypeRegistry;
 import com.google.gerrit.server.mime.MimeUtilFileTypeRegistry;
 import com.google.gerrit.server.notedb.NoteDbModule;
+import com.google.gerrit.server.patch.DiffOperationsImpl;
 import com.google.gerrit.server.patch.PatchListCacheImpl;
 import com.google.gerrit.server.patch.PatchScriptFactory;
 import com.google.gerrit.server.patch.PatchScriptFactoryForAutoFix;
@@ -180,6 +181,7 @@
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ConflictsCacheImpl;
 import com.google.gerrit.server.quota.QuotaEnforcer;
+import com.google.gerrit.server.restapi.change.OnPostReview;
 import com.google.gerrit.server.restapi.change.SuggestReviewers;
 import com.google.gerrit.server.restapi.group.GroupModule;
 import com.google.gerrit.server.rules.DefaultSubmitRule;
@@ -218,12 +220,10 @@
 /** Starts global state with standard dependencies. */
 public class GerritGlobalModule extends FactoryModule {
   private final Config cfg;
-  private final AuthModule authModule;
 
   @Inject
-  GerritGlobalModule(@GerritServerConfig Config cfg, AuthModule authModule) {
+  GerritGlobalModule(@GerritServerConfig Config cfg) {
     this.cfg = cfg;
-    this.authModule = authModule;
   }
 
   @Override
@@ -233,7 +233,6 @@
     bind(IdGenerator.class);
     bind(RulesCache.class);
     bind(BlameCache.class).to(BlameCacheImpl.class);
-    install(authModule);
     install(AccountCacheImpl.module());
     install(BatchUpdate.module());
     install(ChangeKindCacheImpl.module());
@@ -246,11 +245,12 @@
     install(ServiceUserClassifierImpl.module());
     install(PatchListCacheImpl.module());
     install(ProjectCacheImpl.module());
+    install(DiffOperationsImpl.module());
     install(SectionSortCache.module());
     install(SubmitStrategy.module());
     install(TagCache.module());
-    install(OAuthTokenCache.module());
     install(PureRevertCache.module());
+    install(CommentContextCacheImpl.module());
 
     install(new AccessControlModule());
     install(new CmdLineParserModule());
@@ -265,19 +265,20 @@
     install(new IgnoreSelfApprovalRule.Module());
     install(new ReceiveCommitsModule());
     install(new SshAddressesModule());
+    install(new FileInfoJsonModule(cfg));
     install(ThreadLocalRequestContext.module());
 
     factory(CapabilityCollection.Factory.class);
     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);
     factory(ProjectState.Factory.class);
     factory(RevisionJson.Factory.class);
     factory(InboundEmailRejectionSender.Factory.class);
+    factory(ExternalUser.Factory.class);
     bind(PermissionCollection.Factory.class);
     bind(AccountVisibility.class).toProvider(AccountVisibilityProvider.class).in(SINGLETON);
     AccountDefaultDisplayName accountDefaultDisplayName =
@@ -413,6 +414,7 @@
     DynamicSet.setOf(binder(), ExceptionHook.class);
     DynamicSet.bind(binder(), ExceptionHook.class).to(ExceptionHookImpl.class);
     DynamicSet.setOf(binder(), MailSoyTemplateProvider.class);
+    DynamicSet.setOf(binder(), OnPostReview.class);
 
     DynamicMap.mapOf(binder(), MailFilter.class);
     bind(MailFilter.class).annotatedWith(Exports.named("ListMailFilter")).to(ListMailFilter.class);
@@ -437,7 +439,6 @@
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class);
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeHasOperandFactory.class);
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeIsOperandFactory.class);
-    DynamicSet.setOf(binder(), ChangeAttributeFactory.class);
     DynamicSet.setOf(binder(), ChangePluginDefinedInfoFactory.class);
 
     install(new GitwebConfig.LegacyModule(cfg));
diff --git a/java/com/google/gerrit/server/config/GitwebConfig.java b/java/com/google/gerrit/server/config/GitwebConfig.java
index b5f09fd..8214f03 100644
--- a/java/com/google/gerrit/server/config/GitwebConfig.java
+++ b/java/com/google/gerrit/server/config/GitwebConfig.java
@@ -327,8 +327,10 @@
     }
 
     @Override
-    public WebLinkInfo getPatchSetWebLink(String projectName, String commit) {
+    public WebLinkInfo getPatchSetWebLink(
+        String projectName, String commit, String commitMessage, String branchName) {
       if (revision != null) {
+        // commitMessage and branchName are not needed, hence not used.
         return link(
             revision
                 .replace("project", encode(projectName))
@@ -339,9 +341,10 @@
     }
 
     @Override
-    public WebLinkInfo getParentWebLink(String projectName, String commit) {
+    public WebLinkInfo getParentWebLink(
+        String projectName, String commit, String commitMessage, String branchName) {
       // For Gitweb treat parent revision links the same as patch set links
-      return getPatchSetWebLink(projectName, commit);
+      return getPatchSetWebLink(projectName, commit, commitMessage, branchName);
     }
 
     @Override
diff --git a/java/com/google/gerrit/server/data/BUILD b/java/com/google/gerrit/server/data/BUILD
index c3dc672..1aaab96 100644
--- a/java/com/google/gerrit/server/data/BUILD
+++ b/java/com/google/gerrit/server/data/BUILD
@@ -9,7 +9,6 @@
     deps = [
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
-        "//java/org/apache/commons/net",
         "//lib:gson",
     ],
 )
diff --git a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
index 2d5e708..d71f83e 100644
--- a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
+++ b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
@@ -21,14 +21,14 @@
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
-import com.vladsch.flexmark.ast.Block;
 import com.vladsch.flexmark.ast.Heading;
-import com.vladsch.flexmark.ast.Node;
 import com.vladsch.flexmark.ast.util.TextCollectingVisitor;
 import com.vladsch.flexmark.html.HtmlRenderer;
 import com.vladsch.flexmark.parser.Parser;
 import com.vladsch.flexmark.profiles.pegdown.PegdownOptionsAdapter;
-import com.vladsch.flexmark.util.options.MutableDataHolder;
+import com.vladsch.flexmark.util.ast.Block;
+import com.vladsch.flexmark.util.ast.Node;
+import com.vladsch.flexmark.util.data.MutableDataHolder;
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.InputStream;
diff --git a/java/com/google/gerrit/server/documentation/MarkdownFormatterHeader.java b/java/com/google/gerrit/server/documentation/MarkdownFormatterHeader.java
index 00f7ec1..1875b64 100644
--- a/java/com/google/gerrit/server/documentation/MarkdownFormatterHeader.java
+++ b/java/com/google/gerrit/server/documentation/MarkdownFormatterHeader.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.documentation;
 
 import com.vladsch.flexmark.ast.Heading;
-import com.vladsch.flexmark.ast.Node;
 import com.vladsch.flexmark.ext.anchorlink.AnchorLink;
 import com.vladsch.flexmark.ext.anchorlink.internal.AnchorLinkNodeRenderer;
 import com.vladsch.flexmark.html.HtmlRenderer;
@@ -28,8 +27,9 @@
 import com.vladsch.flexmark.html.renderer.NodeRenderingHandler;
 import com.vladsch.flexmark.profiles.pegdown.Extensions;
 import com.vladsch.flexmark.profiles.pegdown.PegdownOptionsAdapter;
-import com.vladsch.flexmark.util.options.DataHolder;
-import com.vladsch.flexmark.util.options.MutableDataHolder;
+import com.vladsch.flexmark.util.ast.Node;
+import com.vladsch.flexmark.util.data.DataHolder;
+import com.vladsch.flexmark.util.data.MutableDataHolder;
 import java.util.Arrays;
 import java.util.HashSet;
 import java.util.Set;
@@ -119,7 +119,7 @@
 
     public static class Factory implements DelegatingNodeRendererFactory {
       @Override
-      public NodeRenderer create(final DataHolder options) {
+      public NodeRenderer apply(final DataHolder options) {
         return new HeadingNodeRenderer();
       }
 
diff --git a/java/com/google/gerrit/server/events/EventGsonProvider.java b/java/com/google/gerrit/server/events/EventGsonProvider.java
index 688507b..72cf7be3 100644
--- a/java/com/google/gerrit/server/events/EventGsonProvider.java
+++ b/java/com/google/gerrit/server/events/EventGsonProvider.java
@@ -15,9 +15,8 @@
 package com.google.gerrit.server.events;
 
 import com.google.common.base.Supplier;
-import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.EntitiesAdapterFactory;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.server.change.ChangeKeyAdapter;
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
 import com.google.inject.Provider;
@@ -29,8 +28,8 @@
         .registerTypeAdapter(Event.class, new EventDeserializer())
         .registerTypeAdapter(Supplier.class, new SupplierSerializer())
         .registerTypeAdapter(Supplier.class, new SupplierDeserializer())
-        .registerTypeAdapter(Change.Key.class, new ChangeKeyAdapter())
         .registerTypeAdapter(Project.NameKey.class, new ProjectNameKeyAdapter())
+        .registerTypeAdapterFactory(EntitiesAdapterFactory.create())
         .create();
   }
 }
diff --git a/java/com/google/gerrit/server/experiments/ConfigExperimentFeatures.java b/java/com/google/gerrit/server/experiments/ConfigExperimentFeatures.java
new file mode 100644
index 0000000..f526935
--- /dev/null
+++ b/java/com/google/gerrit/server/experiments/ConfigExperimentFeatures.java
@@ -0,0 +1,63 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.experiments;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * An implementation of {@link ExperimentFeatures} that uses gerrit.config to evaluate the status of
+ * the feature.
+ */
+@Singleton
+public class ConfigExperimentFeatures implements ExperimentFeatures {
+
+  public static class Module extends AbstractModule {
+    @Override
+    protected void configure() {
+      bind(ExperimentFeatures.class).to(ConfigExperimentFeatures.class);
+    }
+  }
+
+  private ImmutableSet<String> enabledExperimentFeatures;
+
+  @Inject
+  public ConfigExperimentFeatures(@GerritServerConfig Config gerritServerConfig) {
+    Set<String> enabledExperiments = new HashSet<>();
+    Arrays.stream(gerritServerConfig.getStringList("experiments", null, "enabled"))
+        .forEach(enabledExperiments::add);
+    ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES.forEach(enabledExperiments::add);
+    Arrays.stream(gerritServerConfig.getStringList("experiments", null, "disabled"))
+        .forEach(enabledExperiments::remove);
+    enabledExperimentFeatures = ImmutableSet.copyOf(enabledExperiments);
+  }
+
+  @Override
+  public boolean isFeatureEnabled(String featureFlag) {
+    return getEnabledExperimentFeatures().contains(featureFlag);
+  }
+
+  @Override
+  public ImmutableSet<String> getEnabledExperimentFeatures() {
+    return enabledExperimentFeatures;
+  }
+}
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeatures.java b/java/com/google/gerrit/server/experiments/ExperimentFeatures.java
new file mode 100644
index 0000000..dc9148a
--- /dev/null
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeatures.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.experiments;
+
+import com.google.common.collect.ImmutableSet;
+
+/**
+ * Features that can be enabled/disabled on Gerrit (e. g. experiments to research new behavior in
+ * the current release).
+ *
+ * <p>It may depend on the implementation if the result is decided on the per-request basis or not,
+ * so the outcomes should not be persisted in {@link com.google.inject.Singleton}.
+ */
+public interface ExperimentFeatures {
+
+  /**
+   * Given the name of the feature, returns if it is enabled on the Gerrit server.
+   *
+   * <p>Depending on the implementation, it can be more efficient than filtering the results of
+   * {@link ExperimentFeatures#getEnabledExperimentFeatures}.
+   *
+   * @param featureFlag the name of the feature to test.
+   * @return if the feature is enabled.
+   */
+  boolean isFeatureEnabled(String featureFlag);
+
+  /**
+   * Returns the names of the features that are enabled on Gerrit instance (either by default or via
+   * gerrit.config).
+   */
+  ImmutableSet<String> getEnabledExperimentFeatures();
+}
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
new file mode 100644
index 0000000..af49438
--- /dev/null
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.experiments;
+
+import com.google.common.collect.ImmutableSet;
+
+/** Constants for Gerrit {@link ExperimentFeatures} */
+public class ExperimentFeaturesConstants {
+
+  /** Features that are known experiments and can be referenced in the code. */
+  public static String UI_FEATURE_PATCHSET_COMMENTS = "UiFeature__patchset_comments";
+
+  /** Features, enabled by default in the current release. */
+  public static final ImmutableSet<String> DEFAULT_ENABLED_FEATURES =
+      ImmutableSet.of(UI_FEATURE_PATCHSET_COMMENTS);
+}
diff --git a/java/com/google/gerrit/server/git/GroupCollector.java b/java/com/google/gerrit/server/git/GroupCollector.java
index 9e0f2ee..5bbe5e2 100644
--- a/java/com/google/gerrit/server/git/GroupCollector.java
+++ b/java/com/google/gerrit/server/git/GroupCollector.java
@@ -29,7 +29,6 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.git.receive.ReceivePackRefCache;
@@ -43,7 +42,6 @@
 import java.util.Set;
 import java.util.TreeSet;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.revwalk.RevCommit;
 
 /**
@@ -231,7 +229,7 @@
 
   private boolean isGroupFromExistingPatchSet(RevCommit commit, String group) throws IOException {
     ObjectId id = parseGroup(commit, group);
-    return id != null && !receivePackRefCache.tipsFromObjectId(id, RefNames.REFS_CHANGES).isEmpty();
+    return id != null && !receivePackRefCache.patchSetIdsFromObjectId(id).isEmpty();
   }
 
   private Set<String> resolveGroups(ObjectId forCommit, Collection<String> candidates)
@@ -273,17 +271,13 @@
   private Iterable<String> resolveGroup(ObjectId forCommit, String group) throws IOException {
     ObjectId id = parseGroup(forCommit, group);
     if (id != null) {
-      Ref ref =
-          Iterables.getFirst(receivePackRefCache.tipsFromObjectId(id, RefNames.REFS_CHANGES), null);
-      if (ref != null) {
-        PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
-        if (psId != null) {
-          List<String> groups = groupLookup.lookup(psId);
-          // Group for existing patch set may be missing, e.g. if group has not
-          // been migrated yet.
-          if (groups != null && !groups.isEmpty()) {
-            return groups;
-          }
+      PatchSet.Id psId = Iterables.getFirst(receivePackRefCache.patchSetIdsFromObjectId(id), null);
+      if (psId != null) {
+        List<String> groups = groupLookup.lookup(psId);
+        // Group for existing patch set may be missing, e.g. if group has not
+        // been migrated yet.
+        if (groups != null && !groups.isEmpty()) {
+          return groups;
         }
       }
     }
diff --git a/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java b/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
index 44d1077..bf5a0fd 100644
--- a/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
+++ b/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
@@ -148,11 +148,11 @@
   @Override
   public Repository createRepository(Project.NameKey name)
       throws RepositoryNotFoundException, RepositoryCaseMismatchException, IOException {
-    Path path = getBasePath(name);
     if (isUnreasonableName(name)) {
       throw new RepositoryNotFoundException("Invalid name: " + name);
     }
 
+    Path path = getBasePath(name);
     File dir = FileKey.resolve(path.resolve(name.get()).toFile(), FS.DETECTED);
     if (dir != null) {
       // Already exists on disk, use the repository we found.
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index 8666f26..58df343 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -488,6 +488,10 @@
   }
 
   public static String createConflictMessage(List<String> conflicts) {
+    if (conflicts.isEmpty()) {
+      return "";
+    }
+
     StringBuilder sb = new StringBuilder("merge conflict(s):");
     for (String c : conflicts) {
       sb.append('\n').append(c);
@@ -637,11 +641,11 @@
   }
 
   private static boolean isCodeReview(LabelId id) {
-    return "Code-Review".equalsIgnoreCase(id.get());
+    return LabelId.CODE_REVIEW.equalsIgnoreCase(id.get());
   }
 
   private static boolean isVerified(LabelId id) {
-    return "Verified".equalsIgnoreCase(id.get());
+    return LabelId.VERIFIED.equalsIgnoreCase(id.get());
   }
 
   private Iterable<PatchSetApproval> safeGetApprovals(ChangeNotes notes, PatchSet.Id psId) {
diff --git a/java/com/google/gerrit/server/git/MergedByPushOp.java b/java/com/google/gerrit/server/git/MergedByPushOp.java
index 40e2730..78cb013 100644
--- a/java/com/google/gerrit/server/git/MergedByPushOp.java
+++ b/java/com/google/gerrit/server/git/MergedByPushOp.java
@@ -39,6 +39,7 @@
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
+import java.util.Optional;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
 import org.eclipse.jgit.lib.Constants;
@@ -189,8 +190,13 @@
                   @Override
                   public void run() {
                     try {
+                      // The stickyApprovalDiff is always empty here since this is not supported
+                      // for direct pushes.
                       MergedSender emailSender =
-                          mergedSenderFactory.create(ctx.getProject(), psId.changeId());
+                          mergedSenderFactory.create(
+                              ctx.getProject(),
+                              psId.changeId(),
+                              /* stickyApprovalDiff= */ Optional.empty());
                       emailSender.setFrom(ctx.getAccountId());
                       emailSender.setPatchSet(patchSet, info);
                       emailSender.setMessageId(
diff --git a/java/com/google/gerrit/server/git/TagSet.java b/java/com/google/gerrit/server/git/TagSet.java
index 43483bf..d6220a2 100644
--- a/java/com/google/gerrit/server/git/TagSet.java
+++ b/java/com/google/gerrit/server/git/TagSet.java
@@ -16,9 +16,9 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Maps;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto.TagSetProto;
@@ -37,6 +37,7 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectIdOwnerMap;
 import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevSort;
@@ -44,6 +45,12 @@
 
 class TagSet {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final ImmutableSet<String> SKIPPABLE_REF_PREFIXES =
+      ImmutableSet.of(
+          RefNames.REFS_CHANGES,
+          RefNames.REFS_CACHE_AUTOMERGE,
+          RefNames.REFS_DRAFT_COMMENTS,
+          RefNames.REFS_STARRED_CHANGES);
 
   private final Project.NameKey projectName;
   private final Map<String, CachedRef> refs;
@@ -179,7 +186,9 @@
 
     try (TagWalk rw = new TagWalk(git)) {
       rw.setRetainBody(false);
-      for (Ref ref : git.getRefDatabase().getRefs()) {
+      for (Ref ref :
+          git.getRefDatabase()
+              .getRefsByPrefixWithExclusions(RefDatabase.ALL, SKIPPABLE_REF_PREFIXES)) {
         if (skip(ref)) {
           continue;
 
@@ -365,9 +374,7 @@
   static boolean skip(Ref ref) {
     return ref.isSymbolic()
         || ref.getObjectId() == null
-        || PatchSet.isChangeRef(ref.getName())
-        || RefNames.isNoteDbMetaRef(ref.getName())
-        || ref.getName().startsWith(RefNames.REFS_CACHE_AUTOMERGE);
+        || SKIPPABLE_REF_PREFIXES.stream().anyMatch(p -> ref.getName().startsWith(p));
   }
 
   private static boolean isTag(Ref ref) {
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 8d92347..4c90ef9 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -21,6 +21,7 @@
 import static com.google.common.flogger.LazyArgs.lazy;
 import static com.google.gerrit.entities.RefNames.REFS_CHANGES;
 import static com.google.gerrit.entities.RefNames.isConfigRef;
+import static com.google.gerrit.entities.RefNames.isRefsUsersSelf;
 import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static com.google.gerrit.server.change.HashtagsUtil.cleanupHashtag;
 import static com.google.gerrit.server.git.MultiProgressMonitor.UNKNOWN;
@@ -112,6 +113,7 @@
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.SetHashtagsOp;
 import com.google.gerrit.server.change.SetPrivateOp;
+import com.google.gerrit.server.change.SetTopicOp;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.PluginConfig;
@@ -179,7 +181,6 @@
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.server.validators.ValidationException;
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -344,7 +345,8 @@
   private final RequestScopePropagator requestScopePropagator;
   private final Sequences seq;
   private final SetHashtagsOp.Factory hashtagsFactory;
-  private final SubmissionListener superprojectUpdateSubmissionListener;
+  private final SetTopicOp.Factory setTopicFactory;
+  private final ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners;
   private final TagCache tagCache;
   private final ProjectConfig.Factory projectConfigFactory;
   private final SetPrivateOp.Factory setPrivateOpFactory;
@@ -426,7 +428,9 @@
       RequestScopePropagator requestScopePropagator,
       Sequences seq,
       SetHashtagsOp.Factory hashtagsFactory,
-      @SuperprojectUpdateOnSubmission SubmissionListener superprojectUpdateSubmissionListener,
+      SetTopicOp.Factory setTopicFactory,
+      @SuperprojectUpdateOnSubmission
+          ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners,
       TagCache tagCache,
       SetPrivateOp.Factory setPrivateOpFactory,
       ReplyAttentionSetUpdates replyAttentionSetUpdates,
@@ -452,6 +456,7 @@
     this.createGroupPermissionSyncer = createGroupPermissionSyncer;
     this.editUtil = editUtil;
     this.hashtagsFactory = hashtagsFactory;
+    this.setTopicFactory = setTopicFactory;
     this.indexer = indexer;
     this.initializers = initializers;
     this.mergeOpProvider = mergeOpProvider;
@@ -474,7 +479,7 @@
     this.retryHelper = retryHelper;
     this.requestScopePropagator = requestScopePropagator;
     this.seq = seq;
-    this.superprojectUpdateSubmissionListener = superprojectUpdateSubmissionListener;
+    this.superprojectUpdateSubmissionListeners = superprojectUpdateSubmissionListeners;
     this.tagCache = tagCache;
     this.projectConfigFactory = projectConfigFactory;
     this.setPrivateOpFactory = setPrivateOpFactory;
@@ -624,7 +629,7 @@
   private void processCommandsUnsafe(
       Collection<ReceiveCommand> commands, MultiProgressMonitor progress) {
     logger.atFine().log("Calling user: %s", user.getLoggableName());
-    logger.atFine().log("Groups: %s", user.getEffectiveGroups().getKnownGroups());
+    logger.atFine().log("Groups: %s", lazy(() -> user.getEffectiveGroups().getKnownGroups()));
 
     if (!projectState.getProject().getState().permitsWrite()) {
       for (ReceiveCommand cmd : commands) {
@@ -742,7 +747,7 @@
         logger.atFine().log("Added %d additional ref updates", added);
 
         SubmissionExecutor submissionExecutor =
-            new SubmissionExecutor(false, superprojectUpdateSubmissionListener);
+            new SubmissionExecutor(false, superprojectUpdateSubmissionListeners);
 
         submissionExecutor.execute(ImmutableList.of(bu));
 
@@ -1076,7 +1081,7 @@
   private ReceiveCommand wrapReceiveCommand(ReceiveCommand cmd, Task progress) {
     String refname = cmd.getRefName();
 
-    if (projectState.isAllUsers() && RefNames.REFS_USERS_SELF.equals(cmd.getRefName())) {
+    if (isRefsUsersSelf(cmd.getRefName(), projectState.isAllUsers())) {
       refname = RefNames.refsUsers(user.getAccountId());
       logger.atFine().log("Swapping out command for %s to %s", RefNames.REFS_USERS_SELF, refname);
     }
@@ -2145,15 +2150,15 @@
           receivePack.getRevWalk().parseBody(c);
           String name = c.name();
           groupCollector.visit(c);
-          Collection<Ref> existingRefs =
-              receivePackRefCache.tipsFromObjectId(c, RefNames.REFS_CHANGES);
+          Collection<PatchSet.Id> existingPatchSets =
+              receivePackRefCache.patchSetIdsFromObjectId(c);
 
           if (rejectImplicitMerges) {
             Collections.addAll(mergedParents, c.getParents());
             mergedParents.remove(c);
           }
 
-          boolean commitAlreadyTracked = !existingRefs.isEmpty();
+          boolean commitAlreadyTracked = !existingPatchSets.isEmpty();
           if (commitAlreadyTracked) {
             alreadyTracked++;
             // Corner cases where an existing commit might need a new group:
@@ -2169,9 +2174,7 @@
             //      A's group.
             // C) Commit is a PatchSet of a pre-existing change uploaded with a
             //    different target branch.
-            existingRefs.stream()
-                .map(r -> PatchSet.Id.fromRef(r.getName()))
-                .filter(Objects::nonNull)
+            existingPatchSets.stream()
                 .forEach(i -> updateGroups.add(new UpdateGroupsRequest(i, c)));
             if (!(newChangeForAllNotInTarget || magicBranch.base != null)) {
               continue;
@@ -2312,8 +2315,7 @@
 
             // In case the change look up from the index failed,
             // double check against the existing refs
-            if (foundInExistingRef(
-                receivePackRefCache.tipsFromObjectId(p.commit, RefNames.REFS_CHANGES))) {
+            if (foundInExistingPatchSets(receivePackRefCache.patchSetIdsFromObjectId(p.commit))) {
               if (pending.size() == 1) {
                 reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
                 return Collections.emptyList();
@@ -2361,11 +2363,10 @@
     }
   }
 
-  private boolean foundInExistingRef(Collection<Ref> existingRefs) {
-    try (TraceTimer traceTimer = newTimer("foundInExistingRef")) {
-      for (Ref ref : existingRefs) {
-        ChangeNotes notes =
-            notesFactory.create(project.getNameKey(), Change.Id.fromRef(ref.getName()));
+  private boolean foundInExistingPatchSets(Collection<PatchSet.Id> existingPatchSets) {
+    try (TraceTimer traceTimer = newTimer("foundInExistingPatchSet")) {
+      for (PatchSet.Id psId : existingPatchSets) {
+        ChangeNotes notes = notesFactory.create(project.getNameKey(), psId.changeId());
         Change change = notes.getChange();
         if (change.getDest().equals(magicBranch.dest)) {
           logger.atFine().log("Found change %s from existing refs.", change.getKey());
@@ -2598,7 +2599,7 @@
                     .setFireEvent(false));
           }
           if (!Strings.isNullOrEmpty(magicBranch.topic)) {
-            bu.addOp(changeId, new SetTopicOp(magicBranch.topic));
+            bu.addOp(changeId, setTopicFactory.create(magicBranch.topic));
           }
           bu.addOp(
               changeId,
@@ -2837,15 +2838,15 @@
           return false;
         }
 
-        List<Ref> existingChangesWithSameCommit =
-            receivePackRefCache.tipsFromObjectId(newCommit, RefNames.REFS_CHANGES);
-        if (!existingChangesWithSameCommit.isEmpty()) {
+        List<PatchSet.Id> existingPatchSetsWithSameCommit =
+            receivePackRefCache.patchSetIdsFromObjectId(newCommit);
+        if (!existingPatchSetsWithSameCommit.isEmpty()) {
           // TODO(hiesel, hanwen): Remove this check entirely when Gerrit requires change IDs
           //  without the option to turn that off.
           reject(
               inputCommand,
               "commit already exists (in the project): "
-                  + existingChangesWithSameCommit.get(0).getName());
+                  + existingPatchSetsWithSameCommit.get(0).toRefName());
           return false;
         }
 
@@ -3224,7 +3225,7 @@
                     "more than %d commits, and %s not set", limit, PUSH_OPTION_SKIP_VALIDATION));
             return;
           }
-          if (!receivePackRefCache.tipsFromObjectId(c, RefNames.REFS_CHANGES).isEmpty()) {
+          if (!receivePackRefCache.patchSetIdsFromObjectId(c).isEmpty()) {
             continue;
           }
 
@@ -3293,12 +3294,8 @@
 
                       // Check if change refs point to this commit. Usually there are 0-1 change
                       // refs pointing to this commit.
-                      for (Ref ref :
-                          receivePackRefCache.tipsFromObjectId(c.copy(), RefNames.REFS_CHANGES)) {
-                        if (!PatchSet.isChangeRef(ref.getName())) {
-                          continue;
-                        }
-                        PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
+                      for (PatchSet.Id psId :
+                          receivePackRefCache.patchSetIdsFromObjectId(c.copy())) {
                         Optional<ChangeNotes> notes = getChangeNotes(psId.changeId());
                         if (notes.isPresent() && notes.get().getChange().getDest().equals(branch)) {
                           if (submissionId == null) {
@@ -3474,19 +3471,4 @@
     b.append(")\n");
     return b.toString();
   }
-
-  private static class SetTopicOp implements BatchUpdateOp {
-
-    private final String topic;
-
-    public SetTopicOp(String topic) {
-      this.topic = topic;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx) throws ValidationException {
-      ctx.getUpdate(ctx.getChange().currentPatchSetId()).setTopic(topic);
-      return true;
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java b/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java
index 376ab2d..8568810 100644
--- a/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java
+++ b/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java
@@ -21,9 +21,11 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.RefNames;
 import java.io.IOException;
 import java.util.Map;
+import java.util.Objects;
 import org.eclipse.jgit.annotations.Nullable;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -58,8 +60,8 @@
     return new WithAdvertisedRefs(allRefsSupplier);
   }
 
-  /** Returns a list of refs whose name starts with {@code prefix} that point to {@code id}. */
-  ImmutableList<Ref> tipsFromObjectId(ObjectId id, @Nullable String prefix) throws IOException;
+  /** Returns a list of {@link com.google.gerrit.entities.PatchSet.Id}s that point to {@code id}. */
+  ImmutableList<PatchSet.Id> patchSetIdsFromObjectId(ObjectId id) throws IOException;
 
   /** Returns all refs whose name starts with {@code prefix}. */
   ImmutableList<Ref> byPrefix(String prefix) throws IOException;
@@ -76,10 +78,10 @@
     }
 
     @Override
-    public ImmutableList<Ref> tipsFromObjectId(ObjectId id, @Nullable String prefix)
-        throws IOException {
+    public ImmutableList<PatchSet.Id> patchSetIdsFromObjectId(ObjectId id) throws IOException {
       return delegate.getTipsWithSha1(id).stream()
-          .filter(r -> prefix == null || r.getName().startsWith(prefix))
+          .map(r -> PatchSet.Id.fromRef(r.getName()))
+          .filter(Objects::nonNull)
           .collect(toImmutableList());
     }
 
@@ -113,10 +115,11 @@
     }
 
     @Override
-    public ImmutableList<Ref> tipsFromObjectId(ObjectId id, String prefix) {
+    public ImmutableList<PatchSet.Id> patchSetIdsFromObjectId(ObjectId id) {
       lazilyInitRefMaps();
       return refsByObjectId.get(id).stream()
-          .filter(r -> prefix == null || r.getName().startsWith(prefix))
+          .map(r -> PatchSet.Id.fromRef(r.getName()))
+          .filter(Objects::nonNull)
           .collect(toImmutableList());
     }
 
diff --git a/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java b/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
index d507531..6e640f3 100644
--- a/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
+++ b/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidationFailure;
 import com.google.gerrit.extensions.validators.CommentValidator;
+import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.inject.Inject;
@@ -34,6 +35,8 @@
  * issues. Note that autogenerated change messages are not subject to validation.
  */
 public class CommentCumulativeSizeValidator implements CommentValidator {
+  public static final int DEFAULT_CUMULATIVE_COMMENT_SIZE_LIMIT = 3 << 20;
+
   private final int maxCumulativeSize;
   private final ChangeNotes.Factory notesFactory;
 
@@ -41,7 +44,9 @@
   CommentCumulativeSizeValidator(
       @GerritServerConfig Config serverConfig, ChangeNotes.Factory notesFactory) {
     this.notesFactory = notesFactory;
-    maxCumulativeSize = serverConfig.getInt("change", "cumulativeCommentSizeLimit", 3 << 20);
+    maxCumulativeSize =
+        serverConfig.getInt(
+            "change", "cumulativeCommentSizeLimit", DEFAULT_CUMULATIVE_COMMENT_SIZE_LIMIT);
   }
 
   @Override
@@ -55,7 +60,13 @@
                     notes.getRobotComments().values().stream())
                 .mapToInt(Comment::getApproximateSize)
                 .sum()
-            + notes.getChangeMessages().stream().mapToInt(cm -> cm.getMessage().length()).sum();
+            + notes.getChangeMessages().stream()
+                // Auto-generated change messages are not counted for the limit. This method is not
+                // called when those change messages are created, but we should also skip them when
+                // counting the size for unrelated messages.
+                .filter(cm -> !ChangeMessagesUtil.isAutogenerated(cm.getTag()))
+                .mapToInt(cm -> cm.getMessage().length())
+                .sum();
     int newCumulativeSize =
         comments.stream().mapToInt(CommentForValidation::getApproximateSize).sum();
     ImmutableList.Builder<CommentValidationFailure> failures = ImmutableList.builder();
diff --git a/java/com/google/gerrit/server/group/SystemGroupBackend.java b/java/com/google/gerrit/server/group/SystemGroupBackend.java
index b5ccb18..5d50d22 100644
--- a/java/com/google/gerrit/server/group/SystemGroupBackend.java
+++ b/java/com/google/gerrit/server/group/SystemGroupBackend.java
@@ -24,6 +24,8 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.ExternalUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.StartupCheck;
 import com.google.gerrit.server.StartupException;
@@ -90,6 +92,7 @@
   private final SortedMap<String, GroupReference> namesToGroups;
   private final ImmutableSet<String> names;
   private final ImmutableMap<AccountGroup.UUID, GroupReference> uuids;
+  private final ImmutableSet<AccountGroup.UUID> externalUserMemberships;
 
   @Inject
   @VisibleForTesting
@@ -114,6 +117,10 @@
         ImmutableSet.copyOf(
             namesToGroups.values().stream().map(GroupReference::getName).collect(toSet()));
     uuids = u.build();
+    externalUserMemberships =
+        cfg.getBoolean("groups", null, "includeExternalUsersInRegisteredUsersGroup", true)
+            ? ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS)
+            : ImmutableSet.of(ANONYMOUS_USERS);
   }
 
   public GroupReference getGroup(AccountGroup.UUID uuid) {
@@ -182,8 +189,14 @@
   }
 
   @Override
-  public GroupMembership membershipsOf(IdentifiedUser user) {
-    return new ListGroupMembership(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS));
+  public GroupMembership membershipsOf(CurrentUser user) {
+    if (user instanceof ExternalUser) {
+      return new ListGroupMembership(externalUserMemberships);
+    }
+    if (user instanceof IdentifiedUser) {
+      return new ListGroupMembership(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS));
+    }
+    return new ListGroupMembership(ImmutableSet.of(ANONYMOUS_USERS));
   }
 
   public static class NameCheck implements StartupCheck {
diff --git a/java/com/google/gerrit/server/group/testing/BUILD b/java/com/google/gerrit/server/group/testing/BUILD
index fd61dff..77bb777 100644
--- a/java/com/google/gerrit/server/group/testing/BUILD
+++ b/java/com/google/gerrit/server/group/testing/BUILD
@@ -7,7 +7,6 @@
     testonly = True,
     srcs = glob(["*.java"]),
     deps = [
-        "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/server",
         "//lib:guava",
diff --git a/java/com/google/gerrit/server/group/testing/TestGroupBackend.java b/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
index 35f18a2..601ac59 100644
--- a/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
+++ b/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
-import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.project.ProjectState;
@@ -122,7 +122,10 @@
   }
 
   @Override
-  public GroupMembership membershipsOf(IdentifiedUser user) {
+  public GroupMembership membershipsOf(CurrentUser user) {
+    if (!user.isIdentifiedUser()) {
+      return GroupMembership.EMPTY;
+    }
     return memberships.getOrDefault(user.getAccountId(), GroupMembership.EMPTY);
   }
 
diff --git a/java/com/google/gerrit/server/index/IndexUtils.java b/java/com/google/gerrit/server/index/IndexUtils.java
index 9e3d91c..ee8dfc8 100644
--- a/java/com/google/gerrit/server/index/IndexUtils.java
+++ b/java/com/google/gerrit/server/index/IndexUtils.java
@@ -30,7 +30,7 @@
 import com.google.gerrit.server.index.account.AccountField;
 import com.google.gerrit.server.index.group.GroupField;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.SingleGroupUser;
+import com.google.gerrit.server.query.change.GroupBackedUser;
 import java.io.IOException;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -107,7 +107,7 @@
     if (user.isIdentifiedUser()) {
       return user.getAccountId().toString();
     }
-    if (user instanceof SingleGroupUser) {
+    if (user instanceof GroupBackedUser) {
       return "group:" + user.getEffectiveGroups().getKnownGroups().iterator().next().toString();
     }
     return user.toString();
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index ef538cb..59600e0 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -57,7 +57,6 @@
 import com.google.gerrit.entities.converter.PatchSetProtoConverter;
 import com.google.gerrit.entities.converter.ProtoConverter;
 import com.google.gerrit.index.FieldDef;
-import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.SchemaUtil;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.proto.Protos;
@@ -66,9 +65,7 @@
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
-import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gerrit.server.notedb.RobotCommentNotes;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
@@ -152,6 +149,12 @@
   public static final FieldDef<ChangeData, Timestamp> UPDATED =
       timestamp("updated2").stored().build(changeGetter(Change::getLastUpdatedOn));
 
+  /** When this change was merged, time since January 1, 1970. */
+  public static final FieldDef<ChangeData, Timestamp> MERGED_ON =
+      timestamp(ChangeQueryBuilder.FIELD_MERGED_ON)
+          .stored()
+          .build(cd -> cd.getMergedOn().orElse(null));
+
   /** List of full file paths modified in the current patch set. */
   public static final FieldDef<ChangeData, Iterable<String>> PATH =
       // Named for backwards compatibility.
@@ -976,27 +979,9 @@
           .buildRepeatable(
               cd -> {
                 List<byte[]> result = new ArrayList<>();
-                Project.NameKey project = cd.change().getProject();
-
-                cd.editRefs()
-                    .values()
-                    .forEach(r -> result.add(RefState.of(r).toByteArray(project)));
-                cd.starRefs()
-                    .values()
-                    .forEach(r -> result.add(RefState.of(r.ref()).toByteArray(allUsers(cd))));
-
-                ChangeNotes notes = cd.notes();
-                result.add(
-                    RefState.create(notes.getRefName(), notes.getMetaId()).toByteArray(project));
-                notes.getRobotComments(); // Force loading robot comments.
-                RobotCommentNotes robotNotes = notes.getRobotCommentNotes();
-                result.add(
-                    RefState.create(robotNotes.getRefName(), robotNotes.getMetaId())
-                        .toByteArray(project));
-                cd.draftRefs()
-                    .values()
-                    .forEach(r -> result.add(RefState.of(r).toByteArray(allUsers(cd))));
-
+                cd.getRefStates()
+                    .entries()
+                    .forEach(e -> result.add(e.getValue().toByteArray(e.getKey())));
                 return result;
               });
 
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 928f21c..969b071 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -130,9 +130,14 @@
           .build();
 
   /** Added new fields {@link ChangeField#MERGE} */
+  @Deprecated
   static final Schema<ChangeData> V60 =
       new Schema.Builder<ChangeData>().add(V59).add(ChangeField.MERGE).build();
 
+  /** Added new field {@link ChangeField#MERGED_ON} */
+  static final Schema<ChangeData> V61 =
+      new Schema.Builder<ChangeData>().add(V60).add(ChangeField.MERGED_ON).build();
+
   /**
    * Name of the change index to be used when contacting index backends or loading configurations.
    */
diff --git a/java/com/google/gerrit/server/index/change/StalenessChecker.java b/java/com/google/gerrit/server/index/change/StalenessChecker.java
index 7e50104..ad5cc2b 100644
--- a/java/com/google/gerrit/server/index/change/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/change/StalenessChecker.java
@@ -93,7 +93,7 @@
       return StalenessCheckResult.stale("Document %s missing from index", id);
     }
     ChangeData cd = result.get();
-    return check(repoManager, id, parseStates(cd), parsePatterns(cd));
+    return check(repoManager, id, cd.getRefStates(), parsePatterns(cd));
   }
 
   /**
@@ -127,10 +127,6 @@
     return StalenessCheckResult.notStale();
   }
 
-  private SetMultimap<Project.NameKey, RefState> parseStates(ChangeData cd) {
-    return RefState.parseStates(cd.getRefStates());
-  }
-
   private ListMultimap<Project.NameKey, RefStatePattern> parsePatterns(ChangeData cd) {
     return parsePatterns(cd.getRefStatePatterns());
   }
diff --git a/java/com/google/gerrit/server/logging/LoggingContext.java b/java/com/google/gerrit/server/logging/LoggingContext.java
index 671c224..a1b807d 100644
--- a/java/com/google/gerrit/server/logging/LoggingContext.java
+++ b/java/com/google/gerrit/server/logging/LoggingContext.java
@@ -42,11 +42,12 @@
   private static final ThreadLocal<MutableTags> tags = new ThreadLocal<>();
   private static final ThreadLocal<Boolean> forceLogging = new ThreadLocal<>();
   private static final ThreadLocal<Boolean> performanceLogging = new ThreadLocal<>();
+  private static final ThreadLocal<Boolean> aclLogging = new ThreadLocal<>();
 
   /**
-   * When copying the logging context to a new thread we need to ensure that the performance log
-   * records that are added in the new thread are added to the same {@link
-   * MutablePerformanceLogRecords} instance (see {@link LoggingContextAwareRunnable} and {@link
+   * When copying the logging context to a new thread we need to ensure that the mutable log records
+   * (performance logs and ACL logs) that are added in the new thread are added to the same multable
+   * log records instance (see {@link LoggingContextAwareRunnable} and {@link
    * LoggingContextAwareCallable}). This is important since performance log records are processed
    * only at the end of the request and performance log records that are created in another thread
    * should not get lost.
@@ -54,6 +55,8 @@
   private static final ThreadLocal<MutablePerformanceLogRecords> performanceLogRecords =
       new ThreadLocal<>();
 
+  private static final ThreadLocal<MutableAclLogRecords> aclLogRecords = new ThreadLocal<>();
+
   private LoggingContext() {}
 
   /** This method is expected to be called via reflection (and might otherwise be unused). */
@@ -67,7 +70,9 @@
     }
 
     return new LoggingContextAwareRunnable(
-        runnable, getInstance().getMutablePerformanceLogRecords());
+        runnable,
+        getInstance().getMutablePerformanceLogRecords(),
+        getInstance().getMutableAclRecords());
   }
 
   public static <T> Callable<T> copy(Callable<T> callable) {
@@ -76,14 +81,18 @@
     }
 
     return new LoggingContextAwareCallable<>(
-        callable, getInstance().getMutablePerformanceLogRecords());
+        callable,
+        getInstance().getMutablePerformanceLogRecords(),
+        getInstance().getMutableAclRecords());
   }
 
   public boolean isEmpty() {
     return tags.get() == null
         && forceLogging.get() == null
         && performanceLogging.get() == null
-        && performanceLogRecords.get() == null;
+        && performanceLogRecords.get() == null
+        && aclLogging.get() == null
+        && aclLogRecords.get() == null;
   }
 
   public void clear() {
@@ -91,6 +100,8 @@
     forceLogging.remove();
     performanceLogging.remove();
     performanceLogRecords.remove();
+    aclLogging.remove();
+    aclLogRecords.remove();
   }
 
   @Override
@@ -250,6 +261,101 @@
     return records;
   }
 
+  public boolean isAclLogging() {
+    Boolean isAclLogging = aclLogging.get();
+    return isAclLogging != null ? isAclLogging : false;
+  }
+
+  /**
+   * Enables ACL logging.
+   *
+   * <p>It's important to enable ACL logging only in a context that ensures to consume the captured
+   * ACL log records. Otherwise captured ACL log records might leak into other requests that are
+   * executed by the same thread (if a thread pool is used to process requests).
+   *
+   * @param enable whether ACL logging should be enabled.
+   * @return whether ACL logging was be enabled before invoking this method (old value).
+   */
+  boolean aclLogging(boolean enable) {
+    Boolean oldValue = aclLogging.get();
+    if (enable) {
+      aclLogging.set(true);
+    } else {
+      aclLogging.remove();
+    }
+    return oldValue != null ? oldValue : false;
+  }
+
+  /**
+   * Adds an ACL log record.
+   *
+   * @param aclLogRecord ACL log record
+   */
+  public void addAclLogRecord(String aclLogRecord) {
+    if (!isAclLogging()) {
+      return;
+    }
+
+    getMutableAclRecords().add(aclLogRecord);
+  }
+
+  ImmutableList<String> getAclLogRecords() {
+    MutableAclLogRecords records = aclLogRecords.get();
+    if (records != null) {
+      return records.list();
+    }
+    return ImmutableList.of();
+  }
+
+  void clearAclLogEntries() {
+    aclLogRecords.remove();
+  }
+
+  /**
+   * Set the ACL log records in this logging context. Existing log records are overwritten.
+   *
+   * <p>This method makes a defensive copy of the passed in list.
+   *
+   * @param newAclLogRecords ACL log records that should be set
+   */
+  void setAclLogRecords(List<String> newAclLogRecords) {
+    if (newAclLogRecords.isEmpty()) {
+      aclLogRecords.remove();
+      return;
+    }
+
+    getMutableAclRecords().set(newAclLogRecords);
+  }
+
+  /**
+   * Sets a {@link MutableAclLogRecords} instance for storing ACL log records.
+   *
+   * <p><strong>Attention:</strong> The passed in {@link MutableAclLogRecords} instance is directly
+   * stored in the logging context.
+   *
+   * <p>This method is intended to be only used when the logging context is copied to a new thread
+   * to ensure that the ACL log records that are added in the new thread are added to the same
+   * {@link MutableAclLogRecords} instance (see {@link LoggingContextAwareRunnable} and {@link
+   * LoggingContextAwareCallable}). This is important since ACL log records are processed only at
+   * the end of the request and ACL log records that are created in another thread should not get
+   * lost.
+   *
+   * @param mutableAclLogRecords the {@link MutableAclLogRecords} instance in which ACL log records
+   *     should be stored
+   */
+  void setMutableAclLogRecords(MutableAclLogRecords mutableAclLogRecords) {
+    aclLogRecords.set(requireNonNull(mutableAclLogRecords));
+  }
+
+  private MutableAclLogRecords getMutableAclRecords() {
+    MutableAclLogRecords records = aclLogRecords.get();
+    if (records == null) {
+      records = new MutableAclLogRecords();
+      aclLogRecords.set(records);
+    }
+    return records;
+  }
+
   @Override
   public String toString() {
     return MoreObjects.toStringHelper(this)
@@ -257,6 +363,8 @@
         .add("forceLogging", forceLogging.get())
         .add("performanceLogging", performanceLogging.get())
         .add("performanceLogRecords", performanceLogRecords.get())
+        .add("aclLogging", aclLogging.get())
+        .add("aclLogRecords", aclLogRecords.get())
         .toString();
   }
 }
diff --git a/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java b/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
index 1adee1b..ab5db02 100644
--- a/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
+++ b/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
@@ -40,6 +40,8 @@
   private final boolean forceLogging;
   private final boolean performanceLogging;
   private final MutablePerformanceLogRecords mutablePerformanceLogRecords;
+  private final boolean aclLogging;
+  private final MutableAclLogRecords mutableAclLogRecords;
 
   /**
    * Creates a LoggingContextAwareCallable that wraps the given {@link Callable}.
@@ -47,15 +49,21 @@
    * @param callable Callable that should be wrapped.
    * @param mutablePerformanceLogRecords instance of {@link MutablePerformanceLogRecords} to which
    *     performance log records that are created from the runnable are added
+   * @param mutableAclLogRecords instance of {@link MutableAclLogRecords} to which ACL log records
+   *     that are created from the runnable are added
    */
   LoggingContextAwareCallable(
-      Callable<T> callable, MutablePerformanceLogRecords mutablePerformanceLogRecords) {
+      Callable<T> callable,
+      MutablePerformanceLogRecords mutablePerformanceLogRecords,
+      MutableAclLogRecords mutableAclLogRecords) {
     this.callable = callable;
     this.callingThread = Thread.currentThread();
     this.tags = LoggingContext.getInstance().getTagsAsMap();
     this.forceLogging = LoggingContext.getInstance().isLoggingForced();
     this.performanceLogging = LoggingContext.getInstance().isPerformanceLogging();
     this.mutablePerformanceLogRecords = mutablePerformanceLogRecords;
+    this.aclLogging = LoggingContext.getInstance().isAclLogging();
+    this.mutableAclLogRecords = mutableAclLogRecords;
   }
 
   @Override
@@ -76,6 +84,8 @@
     loggingCtx.forceLogging(forceLogging);
     loggingCtx.performanceLogging(performanceLogging);
     loggingCtx.setMutablePerformanceLogRecords(mutablePerformanceLogRecords);
+    loggingCtx.aclLogging(aclLogging);
+    loggingCtx.setMutableAclLogRecords(mutableAclLogRecords);
     try {
       return callable.call();
     } finally {
diff --git a/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java b/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
index d0559cc..3c4c563 100644
--- a/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
+++ b/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
@@ -58,6 +58,8 @@
   private final boolean forceLogging;
   private final boolean performanceLogging;
   private final MutablePerformanceLogRecords mutablePerformanceLogRecords;
+  private final boolean aclLogging;
+  private final MutableAclLogRecords mutableAclLogRecords;
 
   /**
    * Creates a LoggingContextAwareRunnable that wraps the given {@link Runnable}.
@@ -65,15 +67,21 @@
    * @param runnable Runnable that should be wrapped.
    * @param mutablePerformanceLogRecords instance of {@link MutablePerformanceLogRecords} to which
    *     performance log records that are created from the runnable are added
+   * @param mutableAclLogRecords instance of {@link MutableAclLogRecords} to which ACL log records
+   *     that are created from the runnable are added
    */
   LoggingContextAwareRunnable(
-      Runnable runnable, MutablePerformanceLogRecords mutablePerformanceLogRecords) {
+      Runnable runnable,
+      MutablePerformanceLogRecords mutablePerformanceLogRecords,
+      MutableAclLogRecords mutableAclLogRecords) {
     this.runnable = runnable;
     this.callingThread = Thread.currentThread();
     this.tags = LoggingContext.getInstance().getTagsAsMap();
     this.forceLogging = LoggingContext.getInstance().isLoggingForced();
     this.performanceLogging = LoggingContext.getInstance().isPerformanceLogging();
     this.mutablePerformanceLogRecords = mutablePerformanceLogRecords;
+    this.aclLogging = LoggingContext.getInstance().isAclLogging();
+    this.mutableAclLogRecords = mutableAclLogRecords;
   }
 
   public Runnable unwrap() {
@@ -99,6 +107,8 @@
     loggingCtx.forceLogging(forceLogging);
     loggingCtx.performanceLogging(performanceLogging);
     loggingCtx.setMutablePerformanceLogRecords(mutablePerformanceLogRecords);
+    loggingCtx.aclLogging(aclLogging);
+    loggingCtx.setMutableAclLogRecords(mutableAclLogRecords);
     try {
       runnable.run();
     } finally {
diff --git a/java/com/google/gerrit/server/logging/MutableAclLogRecords.java b/java/com/google/gerrit/server/logging/MutableAclLogRecords.java
new file mode 100644
index 0000000..baa9b1f
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/MutableAclLogRecords.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.logging;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Thread-safe store for ACL log records.
+ *
+ * <p>This class is intended to keep track of user ACL records in {@link LoggingContext}. It needs
+ * to be thread-safe because it gets shared between threads when the logging context is copied to
+ * another thread (see {@link LoggingContextAwareRunnable} and {@link LoggingContextAwareCallable}.
+ * In this case the logging contexts of both threads share the same instance of this class. This is
+ * important since ACL log records are processed only at the end of a request and user ACL records
+ * that are created in another thread should not get lost.
+ */
+public class MutableAclLogRecords {
+  private final ArrayList<String> aclLogRecords = new ArrayList<>();
+
+  public synchronized void add(String record) {
+    aclLogRecords.add(record);
+  }
+
+  public synchronized void set(List<String> records) {
+    aclLogRecords.clear();
+    aclLogRecords.addAll(records);
+  }
+
+  public synchronized ImmutableList<String> list() {
+    return ImmutableList.copyOf(aclLogRecords);
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this).add("aclLogRecords", aclLogRecords).toString();
+  }
+}
diff --git a/java/com/google/gerrit/server/logging/TraceContext.java b/java/com/google/gerrit/server/logging/TraceContext.java
index 21a4ce6..2fc19b5 100644
--- a/java/com/google/gerrit/server/logging/TraceContext.java
+++ b/java/com/google/gerrit/server/logging/TraceContext.java
@@ -19,6 +19,7 @@
 import com.google.common.base.Stopwatch;
 import com.google.common.base.Strings;
 import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Table;
 import com.google.common.flogger.FluentLogger;
@@ -222,9 +223,17 @@
   // Table<TAG_NAME, TAG_VALUE, REMOVE_ON_CLOSE>
   private final Table<String, String, Boolean> tags = HashBasedTable.create();
 
-  private boolean stopForceLoggingOnClose;
+  private final boolean oldAclLogging;
+  private final ImmutableList<String> oldAclLogRecords;
 
-  private TraceContext() {}
+  private boolean stopForceLoggingOnClose;
+  private boolean stopAclLoggingOnClose;
+
+  private TraceContext() {
+    // Just in case remember the old state and reset ACL log entries.
+    this.oldAclLogging = LoggingContext.getInstance().isAclLogging();
+    this.oldAclLogRecords = LoggingContext.getInstance().getAclLogRecords();
+  }
 
   public TraceContext addTag(RequestId.Type requestId, Object tagValue) {
     return addTag(requireNonNull(requestId, "request ID is required").name(), tagValue);
@@ -265,6 +274,23 @@
         .findFirst();
   }
 
+  public TraceContext enableAclLogging() {
+    if (stopAclLoggingOnClose) {
+      return this;
+    }
+
+    stopAclLoggingOnClose = !LoggingContext.getInstance().aclLogging(true);
+    return this;
+  }
+
+  public boolean isAclLoggingEnabled() {
+    return LoggingContext.getInstance().isAclLogging();
+  }
+
+  public ImmutableList<String> getAclLogRecords() {
+    return LoggingContext.getInstance().getAclLogRecords();
+  }
+
   @Override
   public void close() {
     for (Table.Cell<String, String, Boolean> cell : tags.cellSet()) {
@@ -275,5 +301,10 @@
     if (stopForceLoggingOnClose) {
       LoggingContext.getInstance().forceLogging(false);
     }
+
+    if (stopAclLoggingOnClose) {
+      LoggingContext.getInstance().aclLogging(oldAclLogging);
+      LoggingContext.getInstance().setAclLogRecords(oldAclLogRecords);
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 8d76e23..a10021a 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -91,12 +91,14 @@
   protected ProjectState projectState;
   protected Set<Account.Id> authors;
   protected boolean emailOnlyAuthors;
+  protected boolean emailOnlyAttentionSetIfEnabled;
 
   protected ChangeEmail(EmailArguments args, String messageClass, ChangeData changeData) {
     super(args, messageClass, changeData.change().getDest());
     this.changeData = changeData;
     this.change = changeData.change();
     this.emailOnlyAuthors = false;
+    this.emailOnlyAttentionSetIfEnabled = true;
     this.currentAttentionSet = getAttentionSet();
   }
 
@@ -401,7 +403,8 @@
     if (!accountState.isPresent()) {
       return;
     }
-    if (accountState.get().generalPreferences().getEmailStrategy()
+    if (emailOnlyAttentionSetIfEnabled
+        && accountState.get().generalPreferences().getEmailStrategy()
             == EmailStrategy.ATTENTION_SET_ONLY
         && !currentAttentionSet.contains(to)) {
       return;
diff --git a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
index d5863a6..0de0dbe 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
@@ -61,7 +61,7 @@
     bccStarredBy();
     ccExistingReviewers();
     includeWatchers(NotifyType.ALL_COMMENTS);
-    add(RecipientType.TO, reviewers);
+    reviewers.stream().forEach(r -> add(RecipientType.TO, r));
     addByEmail(RecipientType.TO, reviewersByEmail);
     removeUsersThatIgnoredTheChange();
   }
diff --git a/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java b/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
index 1b58057..aade30f 100644
--- a/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
@@ -113,6 +113,11 @@
 
   private void addTemplate(SoyFileSet.Builder builder, String resourcePath, String name)
       throws ProvisionException {
+    if (!resourcePath.endsWith("/")) {
+      resourcePath += "/";
+    }
+    String logicalPath = resourcePath + name;
+
     // Load as a file in the mail templates directory if present.
     Path tmpl = site.mail_dir.resolve(name);
     if (Files.isRegularFile(tmpl)) {
@@ -125,14 +130,11 @@
         throw new ProvisionException(
             "Failed to read template file " + tmpl.toAbsolutePath().toString(), err);
       }
-      builder.add(content, tmpl.toAbsolutePath().toString());
+      builder.add(content, logicalPath);
       return;
     }
 
     // Otherwise load the template as a resource.
-    if (!resourcePath.endsWith("/")) {
-      resourcePath += "/";
-    }
-    builder.add(Resources.getResource(resourcePath + name));
+    builder.add(Resources.getResource(logicalPath), logicalPath);
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/MergedSender.java b/java/com/google/gerrit/server/mail/send/MergedSender.java
index 928bdc3..ea76ab8 100644
--- a/java/com/google/gerrit/server/mail/send/MergedSender.java
+++ b/java/com/google/gerrit/server/mail/send/MergedSender.java
@@ -28,24 +28,34 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import java.util.Optional;
 
 /** Send notice about a change successfully merged. */
 public class MergedSender extends ReplyToChangeSender {
   public interface Factory {
-    MergedSender create(Project.NameKey project, Change.Id changeId);
+    MergedSender create(
+        Project.NameKey project, Change.Id changeId, Optional<String> stickyApprovalDiff);
   }
 
   private final LabelTypes labelTypes;
+  private final Optional<String> stickyApprovalDiff;
 
   @Inject
   public MergedSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
+      EmailArguments args,
+      @Assisted Project.NameKey project,
+      @Assisted Change.Id changeId,
+      @Assisted Optional<String> stickyApprovalDiff) {
     super(args, "merged", newChangeData(args, project, changeId));
     labelTypes = changeData.getLabelTypes();
+    this.stickyApprovalDiff = stickyApprovalDiff;
   }
 
   @Override
   protected void init() throws EmailException {
+    // We want to send the submit email even if the "send only when in attention set" is enabled.
+    emailOnlyAttentionSetIfEnabled = false;
+
     super.init();
 
     ccAllApprovals();
@@ -130,5 +140,8 @@
   protected void setupSoyContext() {
     super.setupSoyContext();
     soyContextEmailData.put("approvals", getApprovals());
+    if (stickyApprovalDiff.isPresent()) {
+      soyContextEmailData.put("stickyApprovalDiff", stickyApprovalDiff.get());
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/NewChangeSender.java b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
index 0e97f7e..ee9a328 100644
--- a/java/com/google/gerrit/server/mail/send/NewChangeSender.java
+++ b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
@@ -65,11 +65,11 @@
         break;
       case ALL:
       default:
-        add(RecipientType.CC, extraCC);
+        extraCC.stream().forEach(cc -> add(RecipientType.CC, cc));
         extraCCByEmail.stream().forEach(cc -> add(RecipientType.CC, cc));
         // $FALL-THROUGH$
       case OWNER_REVIEWERS:
-        add(RecipientType.TO, reviewers, true);
+        reviewers.stream().forEach(r -> add(RecipientType.TO, r, true));
         addByEmail(RecipientType.TO, reviewersByEmail, true);
         break;
     }
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index bef5317..44453d5 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.EmailHeader;
 import com.google.gerrit.entities.EmailHeader.AddressList;
-import com.google.gerrit.entities.UserIdentity;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
@@ -284,7 +283,7 @@
     setHeader(MailHeader.AUTO_SUBMITTED.fieldName(), "auto-generated");
 
     for (RecipientType recipientType : notify.accounts().keySet()) {
-      add(recipientType, notify.accounts().get(recipientType));
+      notify.accounts().get(recipientType).stream().forEach(a -> add(recipientType, a));
     }
 
     setHeader(MailHeader.MESSAGE_TYPE.fieldName(), messageClass);
@@ -471,40 +470,18 @@
     return true;
   }
 
-  /** Schedule this message for delivery to the listed accounts. */
-  protected void add(RecipientType rt, Collection<Account.Id> list) {
-    add(rt, list, false);
-  }
-
-  /** Schedule this message for delivery to the listed accounts. */
-  protected void add(RecipientType rt, Collection<Account.Id> list, boolean override) {
-    for (final Account.Id id : list) {
-      add(rt, id, override);
-    }
-  }
-
   /** Schedule this message for delivery to the listed address. */
-  protected void addByEmail(RecipientType rt, Collection<Address> list) {
+  protected final void addByEmail(RecipientType rt, Collection<Address> list) {
     addByEmail(rt, list, false);
   }
 
   /** Schedule this message for delivery to the listed address. */
-  protected void addByEmail(RecipientType rt, Collection<Address> list, boolean override) {
+  protected final void addByEmail(RecipientType rt, Collection<Address> list, boolean override) {
     for (final Address id : list) {
       add(rt, id, override);
     }
   }
 
-  protected void add(RecipientType rt, UserIdentity who) {
-    add(rt, who, false);
-  }
-
-  protected void add(RecipientType rt, UserIdentity who, boolean override) {
-    if (who != null && who.getAccount() != null) {
-      add(rt, who.getAccount(), override);
-    }
-  }
-
   /** Schedule delivery of this message to the given account. */
   protected void add(RecipientType rt, Account.Id to) {
     add(rt, to, false);
@@ -531,11 +508,11 @@
   }
 
   /** Schedule delivery of this message to the given account. */
-  protected void add(RecipientType rt, Address addr) {
+  protected final void add(RecipientType rt, Address addr) {
     add(rt, addr, false);
   }
 
-  protected void add(RecipientType rt, Address addr, boolean override) {
+  protected final void add(RecipientType rt, Address addr, boolean override) {
     if (addr != null && addr.email() != null && addr.email().length() > 0) {
       if (!args.validator.isValid(addr.email())) {
         logger.atWarning().log("Not emailing %s (invalid email address)", addr.email());
diff --git a/java/com/google/gerrit/server/mail/send/ProjectWatch.java b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
index 0514337..173b121 100644
--- a/java/com/google/gerrit/server/mail/send/ProjectWatch.java
+++ b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
@@ -33,7 +33,7 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
-import com.google.gerrit.server.query.change.SingleGroupUser;
+import com.google.gerrit.server.query.change.GroupBackedUser;
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
@@ -150,7 +150,7 @@
       throws QueryParseException {
     logger.atFine().log("Checking watchers for notify config %s from project %s", nc, projectName);
     for (GroupReference groupRef : nc.getGroups()) {
-      CurrentUser user = new SingleGroupUser(groupRef.getUUID());
+      CurrentUser user = new GroupBackedUser(ImmutableSet.of(groupRef.getUUID()));
       if (filterMatch(user, nc.getFilter())) {
         deliverToMembers(matching.list(nc.getHeader()), groupRef.getUUID());
         logger.atFine().log("Added watchers for group %s", groupRef);
diff --git a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
index 5caac37..9516b9f 100644
--- a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
@@ -64,8 +64,8 @@
     if (args.settings.sendNewPatchsetEmails) {
       if (notify.handling() == NotifyHandling.ALL
           || notify.handling() == NotifyHandling.OWNER_REVIEWERS) {
-        add(RecipientType.TO, reviewers);
-        add(RecipientType.CC, extraCC);
+        reviewers.stream().forEach(r -> add(RecipientType.TO, r));
+        extraCC.stream().forEach(cc -> add(RecipientType.CC, cc));
       }
       rcptToAuthors(RecipientType.CC);
     }
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
index 9b5b4d4..a7c7757 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
@@ -37,6 +37,8 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 
 /** View of contents at a single ref related to some change. * */
 public abstract class AbstractChangeNotes<T> {
@@ -118,9 +120,14 @@
   private ObjectId revision;
   private boolean loaded;
 
-  protected AbstractChangeNotes(Args args, Change.Id changeId) {
+  protected AbstractChangeNotes(Args args, Change.Id changeId, @Nullable ObjectId metaSha1) {
     this.args = requireNonNull(args);
     this.changeId = requireNonNull(changeId);
+    this.revision = metaSha1;
+  }
+
+  protected AbstractChangeNotes(Args args, Change.Id changeId) {
+    this(args, changeId, null);
   }
 
   public Change.Id getChangeId() {
@@ -144,7 +151,7 @@
         Repository repo = args.repoManager.openRepository(getProjectName());
         // Call openHandle even if reading is disabled, to trigger
         // auto-rebuilding before this object may get passed to a ChangeUpdate.
-        LoadHandle handle = openHandle(repo)) {
+        LoadHandle handle = openHandle(repo, revision)) {
       revision = handle.id();
       onLoad(handle);
       loaded = true;
@@ -168,13 +175,17 @@
    * @param repo open repository.
    * @return handle for reading the entity.
    * @throws NoSuchChangeException change does not exist.
+   * @throws MissingMetaObjectException specified SHA1 isn't reachable from meta branch.
    * @throws IOException a repo-level error occurred.
    */
-  protected LoadHandle openHandle(Repository repo) throws NoSuchChangeException, IOException {
-    return openHandle(repo, readRef(repo));
-  }
+  protected LoadHandle openHandle(Repository repo, @Nullable ObjectId id)
+      throws NoSuchChangeException, IOException, MissingMetaObjectException {
+    if (id == null) {
+      id = readRef(repo);
+    } else {
+      verifyMetaId(repo, id);
+    }
 
-  protected LoadHandle openHandle(Repository repo, ObjectId id) {
     return new LoadHandle(repo, id);
   }
 
@@ -215,4 +226,20 @@
   protected final T self() {
     return (T) this;
   }
+
+  private void verifyMetaId(Repository repo, ObjectId id)
+      throws IOException, MissingMetaObjectException {
+    try (RevWalk rw = new RevWalk(repo)) {
+      Ref ref = repo.getRefDatabase().exactRef(getRefName());
+      RevCommit tip = rw.parseCommit(ref.getObjectId());
+      rw.markStart(tip);
+      for (RevCommit rev : rw) {
+        if (id.equals(rev)) {
+          return;
+        }
+      }
+    }
+
+    throw new MissingMetaObjectException(id.getName() + " not reachable from " + getRefName());
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index c35d815..77d7bc0 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -64,6 +64,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
@@ -77,6 +78,9 @@
 import org.eclipse.jgit.lib.Repository;
 
 /** View of a single {@link Change} based on the log of its notes branch. */
+// TODO(paiking): This class should be refactored to get rid of potentially duplicate or unneeded
+// variables, such as allAttentionSetUpdates, reviewerUpdates, and others.
+
 public class ChangeNotes extends AbstractChangeNotes<ChangeNotes> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -110,9 +114,14 @@
       return createChecked(c.getProject(), c.getId());
     }
 
-    public ChangeNotes createChecked(Project.NameKey project, Change.Id changeId) {
+    public ChangeNotes createChecked(
+        Project.NameKey project, Change.Id changeId, @Nullable ObjectId metaRevId) {
       Change change = newChange(project, changeId);
-      return new ChangeNotes(args, change, true, null).load();
+      return new ChangeNotes(args, change, true, null, metaRevId).load();
+    }
+
+    public ChangeNotes createChecked(Project.NameKey project, Change.Id changeId) {
+      return createChecked(project, changeId, null);
     }
 
     public static Change newChange(Project.NameKey project, Change.Id changeId) {
@@ -125,17 +134,6 @@
       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
-     */
-    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();
     }
@@ -145,8 +143,9 @@
     }
 
     /**
-     * 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.
+     * Create change notes based on a {@link com.google.gerrit.entities.Change.Id}. This requires
+     * using the Change index and should only be used when {@link
+     * com.google.gerrit.entities.Project.NameKey} and the numeric change ID are not available.
      */
     public ChangeNotes createCheckedUsingIndexLookup(Change.Id changeId) {
       InternalChangeQuery query = queryProvider.get().noFields();
@@ -162,9 +161,9 @@
     }
 
     /**
-     * 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.
+     * Create change notes based on a list of {@link com.google.gerrit.entities.Change.Id}s. This
+     * requires using the Change index and should only be used when {@link
+     * com.google.gerrit.entities.Project.NameKey} and the numeric change ID are not available.
      */
     public List<ChangeNotes> createUsingIndexLookup(Collection<Change.Id> changeIds) {
       List<ChangeNotes> notes = new ArrayList<>();
@@ -339,14 +338,23 @@
   private ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals;
   private ImmutableSet<Comment.Key> commentKeys;
 
-  @VisibleForTesting
-  public ChangeNotes(Args args, Change change, boolean shouldExist, @Nullable RefCache refs) {
-    super(args, change.getId());
+  public ChangeNotes(
+      Args args,
+      Change change,
+      boolean shouldExist,
+      @Nullable RefCache refs,
+      @Nullable ObjectId metaSha1) {
+    super(args, change.getId(), metaSha1);
     this.change = new Change(change);
     this.shouldExist = shouldExist;
     this.refs = refs;
   }
 
+  @VisibleForTesting
+  public ChangeNotes(Args args, Change change, boolean shouldExist, @Nullable RefCache refs) {
+    this(args, change, shouldExist, refs, null);
+  }
+
   public Change getChange() {
     return change;
   }
@@ -400,6 +408,11 @@
     return state.attentionSet();
   }
 
+  /** Returns all updates for the attention set. */
+  public ImmutableList<AttentionSetUpdate> getAttentionSetUpdates() {
+    return state.allAttentionSetUpdates();
+  }
+
   /**
    * @return an ImmutableSet of Account.Ids of all users that have been assigned to this change. The
    *     order of the set is the order in which they were assigned.
@@ -463,6 +476,11 @@
     return state.updateCount();
   }
 
+  /** @return {@link Optional} value of time when the change was merged. */
+  public Optional<Timestamp> getMergedOn() {
+    return Optional.ofNullable(state.mergedOn());
+  }
+
   public ImmutableListMultimap<ObjectId, HumanComment> getDraftComments(Account.Id author) {
     return getDraftComments(author, null);
   }
@@ -508,6 +526,7 @@
   }
 
   public RobotCommentNotes getRobotCommentNotes() {
+    loadRobotComments();
     return robotCommentNotes;
   }
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index 7fde297..c554ca5 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -121,12 +121,18 @@
     // Single Timestamp overhead.
     private static final int T = O + 8;
 
+    /**
+     * {@inheritDoc}
+     *
+     * <p>Take all columns and all collection sizes into account, but use estimated average element
+     * sizes rather than iterating over collections. Numbers are largely hand-wavy based on
+     * http://stackoverflow.com/questions/258120/what-is-the-memory-consumption-of-an-object-in-java
+     *
+     * <p>Should be kept up to date with {@link ChangeNotesState}. Please, keep weights listed in
+     * the same order as fields.
+     */
     @Override
     public int weigh(Key key, ChangeNotesState state) {
-      // Take all columns and all collection sizes into account, but use
-      // estimated average element sizes rather than iterating over collections.
-      // Numbers are largely hand-wavy based on
-      // http://stackoverflow.com/questions/258120/what-is-the-memory-consumption-of-an-object-in-java
       return P
           + O
           + 20 // metaId
@@ -138,6 +144,7 @@
           + K // owner
           + P
           + str(state.columns().branch())
+          + P // status
           + P
           + patchSetId() // currentPatchSetId
           + P
@@ -148,9 +155,16 @@
           + str(state.columns().originalSubject())
           + P
           + str(state.columns().submissionId())
-          + P // status
+          + 1 // isPrivate
+          + 1 // workInProgress
+          + 1 // reviewStarted
+          + P
+          + K // revertOf
+          + P
+          + patchSetId() // cherryPickOf
           + P
           + set(state.hashtags(), str(10))
+          + str(state.serverId()) // serverId
           + P
           + list(state.patchSets(), patchSet())
           + P
@@ -168,15 +182,17 @@
           + P
           + list(state.assigneeUpdates(), 4 * O + K + K)
           + P
+          + set(state.attentionSet(), 4 * O + K + I + str(15))
+          + P
+          + list(state.allAttentionSetUpdates(), 4 * O + K + I + str(15))
+          + P
           + list(state.submitRecords(), P + list(2, str(4) + P + K) + P)
           + P
           + list(state.changeMessages(), changeMessage())
           + P
           + map(state.publishedComments().asMap(), comment())
-          + 1 // isPrivate
-          + 1 // workInProgress
-          + 1 // reviewStarted
-          + I; // updateCount
+          + I // updateCount
+          + T; // mergedOn
     }
 
     private static int str(String s) {
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index c92d236..97fec4c 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -55,6 +55,7 @@
 import com.google.common.collect.Tables;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.AttentionSetUpdate;
@@ -118,6 +119,8 @@
   private final List<ReviewerStatusUpdate> reviewerUpdates;
   /** Holds only the most recent update per user. Older updates are discarded. */
   private final Map<Account.Id, AttentionSetUpdate> latestAttentionStatus;
+  /** Holds all updates to attention set. */
+  private final List<AttentionSetUpdate> allAttentionSetUpdates;
 
   private final List<AssigneeStatusUpdate> assigneeUpdates;
   private final List<SubmitRecord> submitRecords;
@@ -153,7 +156,11 @@
   private ReviewerByEmailSet pendingReviewersByEmail;
   private Change.Id revertOf;
   private int updateCount;
-  private PatchSet.Id cherryPickOf;
+  // Null indicates that the field was not parsed (yet).
+  // We only set the value once, based on the latest update (the actual value or Optional.empty() if
+  // the latest record unsets the field).
+  private Optional<PatchSet.Id> cherryPickOf;
+  private Timestamp mergedOn;
 
   ChangeNotesParser(
       Change.Id changeId,
@@ -175,6 +182,7 @@
     allPastReviewers = new ArrayList<>();
     reviewerUpdates = new ArrayList<>();
     latestAttentionStatus = new HashMap<>();
+    allAttentionSetUpdates = new ArrayList<>();
     assigneeUpdates = new ArrayList<>();
     submitRecords = Lists.newArrayListWithExpectedSize(1);
     allChangeMessages = new ArrayList<>();
@@ -246,6 +254,7 @@
         allPastReviewers,
         buildReviewerUpdates(),
         ImmutableSet.copyOf(latestAttentionStatus.values()),
+        allAttentionSetUpdates,
         assigneeUpdates,
         submitRecords,
         buildAllMessages(),
@@ -254,8 +263,9 @@
         firstNonNull(workInProgress, false),
         firstNonNull(hasReviewStarted, true),
         revertOf,
-        cherryPickOf,
-        updateCount);
+        cherryPickOf != null ? cherryPickOf.orElse(null) : null,
+        updateCount,
+        mergedOn);
   }
 
   private Map<PatchSet.Id, PatchSet> buildPatchSets() throws ConfigInvalidException {
@@ -318,9 +328,9 @@
 
   private void parse(ChangeNotesCommit commit) throws ConfigInvalidException {
     updateCount++;
-    Timestamp ts = new Timestamp(commit.getCommitterIdent().getWhen().getTime());
+    Timestamp commitTimestamp = getCommitTimestamp(commit);
 
-    createdOn = ts;
+    createdOn = commitTimestamp;
     parseTag(commit);
 
     if (branch == null) {
@@ -360,21 +370,19 @@
       originalSubject = currSubject;
     }
 
-    parseChangeMessage(psId, accountId, realAccountId, commit, ts);
+    parseChangeMessage(psId, accountId, realAccountId, commit, commitTimestamp);
     if (topic == null) {
       topic = parseTopic(commit);
     }
 
     parseHashtags(commit);
     parseAttentionSetUpdates(commit);
-    parseAssigneeUpdates(ts, commit);
+    parseAssigneeUpdates(commitTimestamp, commit);
 
-    if (submissionId == null) {
-      submissionId = parseSubmissionId(commit);
-    }
+    parseSubmission(commit, commitTimestamp);
 
-    if (lastUpdatedOn == null || ts.after(lastUpdatedOn)) {
-      lastUpdatedOn = ts;
+    if (lastUpdatedOn == null || commitTimestamp.after(lastUpdatedOn)) {
+      lastUpdatedOn = commitTimestamp;
     }
 
     if (deletedPatchSets.contains(psId)) {
@@ -388,16 +396,10 @@
 
     ObjectId currRev = parseRevision(commit);
     if (currRev != null) {
-      parsePatchSet(psId, currRev, accountId, ts);
+      parsePatchSet(psId, currRev, accountId, commitTimestamp);
     }
     parseCurrentPatchSet(psId, commit);
 
-    if (submitRecords.isEmpty()) {
-      // Only parse the most recent set of submit records; any older ones are
-      // still there, but not currently used.
-      parseSubmitRecords(commit.getFooterLineValues(FOOTER_SUBMITTED_WITH));
-    }
-
     if (status == null) {
       status = parseStatus(commit);
     }
@@ -405,15 +407,15 @@
     // Parse approvals after status to treat approvals in the same commit as
     // "Status: merged" as non-post-submit.
     for (String line : commit.getFooterLineValues(FOOTER_LABEL)) {
-      parseApproval(psId, accountId, realAccountId, ts, line);
+      parseApproval(psId, accountId, realAccountId, commitTimestamp, line);
     }
 
     for (ReviewerStateInternal state : ReviewerStateInternal.values()) {
       for (String line : commit.getFooterLineValues(state.getFooterKey())) {
-        parseReviewer(ts, state, line);
+        parseReviewer(commitTimestamp, state, line);
       }
       for (String line : commit.getFooterLineValues(state.getByEmailFooterKey())) {
-        parseReviewerByEmail(ts, state, line);
+        parseReviewerByEmail(commitTimestamp, state, line);
       }
       // Don't update timestamp when a reviewer was added, matching RevewDb
       // behavior.
@@ -435,6 +437,24 @@
     parseWorkInProgress(commit);
   }
 
+  private void parseSubmission(ChangeNotesCommit commit, Timestamp commitTimestamp)
+      throws ConfigInvalidException {
+    // Only parse the most recent sumbit commit (there should be exactly one).
+    if (submissionId == null) {
+      submissionId = parseSubmissionId(commit);
+    }
+
+    if (submissionId != null && mergedOn == null) {
+      mergedOn = commitTimestamp;
+    }
+
+    if (submitRecords.isEmpty()) {
+      // Only parse the most recent set of submit records; any older ones are
+      // still there, but not currently used.
+      parseSubmitRecords(commit.getFooterLineValues(FOOTER_SUBMITTED_WITH));
+    }
+  }
+
   private String parseSubmissionId(ChangeNotesCommit commit) throws ConfigInvalidException {
     return parseOneFooter(commit, FOOTER_SUBMISSION_ID);
   }
@@ -589,6 +609,9 @@
       }
       // Processing is in reverse chronological order. Keep only the latest update.
       latestAttentionStatus.putIfAbsent(attentionStatus.get().account(), attentionStatus.get());
+
+      // Keep all updates as well.
+      allAttentionSetUpdates.add(attentionStatus.get());
     }
   }
 
@@ -997,16 +1020,49 @@
     return Change.id(revertOf);
   }
 
-  private PatchSet.Id parseCherryPickOf(ChangeNotesCommit commit) throws ConfigInvalidException {
-    String cherryPickOf = parseOneFooter(commit, FOOTER_CHERRY_PICK_OF);
-    if (cherryPickOf == null) {
+  /**
+   * Parses {@link ChangeNoteUtil#FOOTER_CHERRY_PICK_OF} of the commit.
+   *
+   * @param commit the commit to parse.
+   * @return {@link Optional} value of the parsed footer or {@code null} if the footer is missing in
+   *     this commit.
+   * @throws ConfigInvalidException if the footer value could not be parsed as a valid {@link
+   *     com.google.gerrit.entities.PatchSet.Id}.
+   */
+  @Nullable
+  private Optional<PatchSet.Id> parseCherryPickOf(ChangeNotesCommit commit)
+      throws ConfigInvalidException {
+    String footer = parseOneFooter(commit, FOOTER_CHERRY_PICK_OF);
+    if (footer == null) {
+      // The footer is missing, nothing to parse.
       return null;
+    } else if (footer.equals("")) {
+      // Empty footer value, cherryPickOf was unset at this commit.
+      return Optional.empty();
+    } else {
+      try {
+        return Optional.of(PatchSet.Id.parse(footer));
+      } catch (IllegalArgumentException e) {
+        throw new ConfigInvalidException("\"" + footer + "\" is not a valid patchset", e);
+      }
     }
-    try {
-      return PatchSet.Id.parse(cherryPickOf);
-    } catch (IllegalArgumentException e) {
-      throw new ConfigInvalidException("\"" + cherryPickOf + "\" is not a valid patchset", e);
-    }
+  }
+
+  /**
+   * Returns the {@link Timestamp} when the commit was applied.
+   *
+   * <p>The author's date only notes when the commit was originally made. Thus, use the commiter's
+   * date as it accounts for the rebase, cherry-pick, commit --amend and other commands that rewrite
+   * the history of the branch.
+   *
+   * <p>Don't use {@link org.eclipse.jgit.revwalk.RevCommit#getCommitTime} directly because it
+   * returns int and would overflow.
+   *
+   * @param commit the commit to return commit time.
+   * @return the timestamp when the commit was applied.
+   */
+  private Timestamp getCommitTimestamp(ChangeNotesCommit commit) {
+    return new Timestamp(commit.getCommitterIdent().getWhen().getTime());
   }
 
   private void pruneReviewers() {
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 76c4678..33bc039 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -47,7 +47,6 @@
 import com.google.gerrit.entities.converter.ChangeMessageProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetProtoConverter;
-import com.google.gerrit.entities.converter.ProtoConverter;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.AssigneeStatusUpdate;
@@ -65,8 +64,6 @@
 import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import com.google.gerrit.server.index.change.ChangeField.StoredSubmitRecord;
 import com.google.gson.Gson;
-import com.google.protobuf.ByteString;
-import com.google.protobuf.MessageLite;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.List;
@@ -81,9 +78,15 @@
  * <p>One instance is the output of a single {@link ChangeNotesParser}, and contains types required
  * to support public methods on {@link ChangeNotes}. It is intended to be cached in-process.
  *
+ * <p>When new fields are added to the {@link ChangeNotesState}, {@link
+ * ChangeNotesCache.Weigher#weigh} should be updated.
+ *
  * <p>Note that {@link ChangeNotes} contains more than just a single {@code ChangeNoteState}, such
  * as per-draft information, so that class is not cached directly.
  */
+// TODO(paiking): This class should be refactored to get rid of potentially duplicate or unneeded
+// variables, such as allAttentionSetUpdates, reviewerUpdates, and others.
+
 @AutoValue
 public abstract class ChangeNotesState {
 
@@ -120,6 +123,7 @@
       List<Account.Id> allPastReviewers,
       List<ReviewerStatusUpdate> reviewerUpdates,
       Set<AttentionSetUpdate> attentionSetUpdates,
+      List<AttentionSetUpdate> allAttentionSetUpdates,
       List<AssigneeStatusUpdate> assigneeUpdates,
       List<SubmitRecord> submitRecords,
       List<ChangeMessage> changeMessages,
@@ -129,7 +133,8 @@
       boolean reviewStarted,
       @Nullable Change.Id revertOf,
       @Nullable PatchSet.Id cherryPickOf,
-      int updateCount) {
+      int updateCount,
+      @Nullable Timestamp mergedOn) {
     requireNonNull(
         metaId,
         () ->
@@ -171,11 +176,13 @@
         .allPastReviewers(allPastReviewers)
         .reviewerUpdates(reviewerUpdates)
         .attentionSet(attentionSetUpdates)
+        .allAttentionSetUpdates(allAttentionSetUpdates)
         .assigneeUpdates(assigneeUpdates)
         .submitRecords(submitRecords)
         .changeMessages(changeMessages)
         .publishedComments(publishedComments)
         .updateCount(updateCount)
+        .mergedOn(mergedOn)
         .build();
   }
 
@@ -305,9 +312,12 @@
 
   abstract ImmutableList<ReviewerStatusUpdate> reviewerUpdates();
 
-  /** Returns the most recent update (i.e. current status status) per user. */
+  /** Returns the most recent update (i.e. current status) per user. */
   abstract ImmutableSet<AttentionSetUpdate> attentionSet();
 
+  /** Returns all attention set updates. */
+  abstract ImmutableList<AttentionSetUpdate> allAttentionSetUpdates();
+
   abstract ImmutableList<AssigneeStatusUpdate> assigneeUpdates();
 
   abstract ImmutableList<SubmitRecord> submitRecords();
@@ -318,6 +328,9 @@
 
   abstract int updateCount();
 
+  @Nullable
+  abstract Timestamp mergedOn();
+
   Change newChange(Project.NameKey project) {
     ChangeColumns c = requireNonNull(columns(), "columns are required");
     Change change =
@@ -386,6 +399,7 @@
           .allPastReviewers(ImmutableList.of())
           .reviewerUpdates(ImmutableList.of())
           .attentionSet(ImmutableSet.of())
+          .allAttentionSetUpdates(ImmutableList.of())
           .assigneeUpdates(ImmutableList.of())
           .submitRecords(ImmutableList.of())
           .changeMessages(ImmutableList.of())
@@ -421,6 +435,8 @@
 
     abstract Builder attentionSet(Set<AttentionSetUpdate> attentionSetUpdates);
 
+    abstract Builder allAttentionSetUpdates(List<AttentionSetUpdate> attentionSetUpdates);
+
     abstract Builder assigneeUpdates(List<AssigneeStatusUpdate> assigneeUpdates);
 
     abstract Builder submitRecords(List<SubmitRecord> submitRecords);
@@ -431,9 +447,14 @@
 
     abstract Builder updateCount(int updateCount);
 
+    abstract Builder mergedOn(Timestamp mergedOn);
+
     abstract ChangeNotesState build();
   }
 
+  /**
+   * Convert ChangeNotesState (which is AutoValue based) to byte[] and back, using protocol buffers.
+   */
   enum Serializer implements CacheSerializer<ChangeNotesState> {
     INSTANCE;
 
@@ -461,13 +482,11 @@
       object.hashtags().forEach(b::addHashtag);
       object
           .patchSets()
-          .forEach(e -> b.addPatchSet(toByteString(e.getValue(), PatchSetProtoConverter.INSTANCE)));
+          .forEach(e -> b.addPatchSet(PatchSetProtoConverter.INSTANCE.toProto(e.getValue())));
       object
           .approvals()
           .forEach(
-              e ->
-                  b.addApproval(
-                      toByteString(e.getValue(), PatchSetApprovalProtoConverter.INSTANCE)));
+              e -> b.addApproval(PatchSetApprovalProtoConverter.INSTANCE.toProto(e.getValue())));
 
       object.reviewers().asTable().cellSet().forEach(c -> b.addReviewer(toReviewerSetEntry(c)));
       object
@@ -489,25 +508,26 @@
       object.allPastReviewers().forEach(a -> b.addPastReviewer(a.get()));
       object.reviewerUpdates().forEach(u -> b.addReviewerUpdate(toReviewerStatusUpdateProto(u)));
       object.attentionSet().forEach(u -> b.addAttentionSetUpdate(toAttentionSetUpdateProto(u)));
+      object
+          .allAttentionSetUpdates()
+          .forEach(u -> b.addAllAttentionSetUpdate(toAttentionSetUpdateProto(u)));
       object.assigneeUpdates().forEach(u -> b.addAssigneeUpdate(toAssigneeStatusUpdateProto(u)));
       object
           .submitRecords()
           .forEach(r -> b.addSubmitRecord(GSON.toJson(new StoredSubmitRecord(r))));
       object
           .changeMessages()
-          .forEach(m -> b.addChangeMessage(toByteString(m, ChangeMessageProtoConverter.INSTANCE)));
+          .forEach(m -> b.addChangeMessage(ChangeMessageProtoConverter.INSTANCE.toProto(m)));
       object.publishedComments().values().forEach(c -> b.addPublishedComment(GSON.toJson(c)));
       b.setUpdateCount(object.updateCount());
+      if (object.mergedOn() != null) {
+        b.setMergedOnMillis(object.mergedOn().getTime());
+        b.setHasMergedOn(true);
+      }
 
       return Protos.toByteArray(b.build());
     }
 
-    @VisibleForTesting
-    static <T> ByteString toByteString(T object, ProtoConverter<?, T> converter) {
-      MessageLite message = converter.toProto(object);
-      return Protos.toByteString(message);
-    }
-
     private static ChangeColumnsProto toChangeColumnsProto(ChangeColumns cols) {
       ChangeColumnsProto.Builder b =
           ChangeColumnsProto.newBuilder()
@@ -607,12 +627,12 @@
               .hashtags(proto.getHashtagList())
               .patchSets(
                   proto.getPatchSetList().stream()
-                      .map(bytes -> parseProtoFrom(PatchSetProtoConverter.INSTANCE, bytes))
+                      .map(msg -> PatchSetProtoConverter.INSTANCE.fromProto(msg))
                       .map(ps -> Maps.immutableEntry(ps.id(), ps))
                       .collect(toImmutableList()))
               .approvals(
                   proto.getApprovalList().stream()
-                      .map(bytes -> parseProtoFrom(PatchSetApprovalProtoConverter.INSTANCE, bytes))
+                      .map(msg -> PatchSetApprovalProtoConverter.INSTANCE.fromProto(msg))
                       .map(a -> Maps.immutableEntry(a.patchSetId(), a))
                       .collect(toImmutableList()))
               .reviewers(toReviewerSet(proto.getReviewerList()))
@@ -623,6 +643,8 @@
                   proto.getPastReviewerList().stream().map(Account::id).collect(toImmutableList()))
               .reviewerUpdates(toReviewerStatusUpdateList(proto.getReviewerUpdateList()))
               .attentionSet(toAttentionSetUpdates(proto.getAttentionSetUpdateList()))
+              .allAttentionSetUpdates(
+                  toAllAttentionSetUpdates(proto.getAllAttentionSetUpdateList()))
               .assigneeUpdates(toAssigneeStatusUpdateList(proto.getAssigneeUpdateList()))
               .submitRecords(
                   proto.getSubmitRecordList().stream()
@@ -630,22 +652,17 @@
                       .collect(toImmutableList()))
               .changeMessages(
                   proto.getChangeMessageList().stream()
-                      .map(bytes -> parseProtoFrom(ChangeMessageProtoConverter.INSTANCE, bytes))
+                      .map(msg -> ChangeMessageProtoConverter.INSTANCE.fromProto(msg))
                       .collect(toImmutableList()))
               .publishedComments(
                   proto.getPublishedCommentList().stream()
                       .map(r -> GSON.fromJson(r, HumanComment.class))
                       .collect(toImmutableListMultimap(HumanComment::getCommitId, c -> c)))
-              .updateCount(proto.getUpdateCount());
+              .updateCount(proto.getUpdateCount())
+              .mergedOn(proto.getHasMergedOn() ? new Timestamp(proto.getMergedOnMillis()) : null);
       return b.build();
     }
 
-    private static <P extends MessageLite, T> T parseProtoFrom(
-        ProtoConverter<P, T> converter, ByteString byteString) {
-      P message = Protos.parseUnchecked(converter.getParser(), byteString);
-      return converter.fromProto(message);
-    }
-
     private static ChangeColumns toChangeColumns(Change.Id changeId, ChangeColumnsProto proto) {
       ChangeColumns.Builder b =
           ChangeColumns.builder()
@@ -735,6 +752,20 @@
       return b.build();
     }
 
+    private static ImmutableList<AttentionSetUpdate> toAllAttentionSetUpdates(
+        List<AttentionSetUpdateProto> protos) {
+      ImmutableList.Builder<AttentionSetUpdate> b = ImmutableList.builder();
+      for (AttentionSetUpdateProto proto : protos) {
+        b.add(
+            AttentionSetUpdate.createFromRead(
+                Instant.ofEpochMilli(proto.getTimestampMillis()),
+                Account.id(proto.getAccount()),
+                AttentionSetUpdate.Operation.valueOf(proto.getOperation()),
+                proto.getReason()));
+      }
+      return b.build();
+    }
+
     private static ImmutableList<AssigneeStatusUpdate> toAssigneeStatusUpdateList(
         List<AssigneeStatusUpdateProto> protos) {
       ImmutableList.Builder<AssigneeStatusUpdate> b = ImmutableList.builder();
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index c3b0e79..9d23137 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;
@@ -152,7 +154,9 @@
   private Boolean isPrivate;
   private Boolean workInProgress;
   private Integer revertOf;
-  private String cherryPickOf;
+  // If null, the update does not modify the field. Otherwise, it updates the field with the
+  // new value or resets if cherryPickOf == Optional.empty().
+  private Optional<String> cherryPickOf;
 
   private ChangeDraftUpdate draftUpdate;
   private RobotCommentUpdate robotCommentUpdate;
@@ -473,7 +477,12 @@
   }
 
   public void setCherryPickOf(String cherryPickOf) {
-    this.cherryPickOf = cherryPickOf;
+    checkArgument(cherryPickOf != null, "use resetCherryPickOf");
+    this.cherryPickOf = Optional.of(cherryPickOf);
+  }
+
+  public void resetCherryPickOf() {
+    this.cherryPickOf = Optional.empty();
   }
 
   /** @return the tree id for the updated tree */
@@ -736,7 +745,12 @@
     }
 
     if (cherryPickOf != null) {
-      addFooter(msg, FOOTER_CHERRY_PICK_OF, cherryPickOf);
+      if (cherryPickOf.isPresent()) {
+        addFooter(msg, FOOTER_CHERRY_PICK_OF, cherryPickOf.get());
+      } else {
+        // Update cherryPickOf with an empty value.
+        addFooter(msg, FOOTER_CHERRY_PICK_OF).append('\n');
+      }
     }
 
     if (plannedAttentionSetUpdates != null) {
@@ -780,21 +794,25 @@
         getNotes().getReviewers().byState(ReviewerStateInternal.REVIEWER);
     Set<AttentionSetUpdate> updates = new HashSet<>();
     for (Map.Entry<Account.Id, ReviewerStateInternal> reviewer : reviewers.entrySet()) {
-      // Only add new reviewers to the attention set.
-      if (reviewer.getValue().equals(ReviewerStateInternal.REVIEWER)
-          && !currentReviewers.contains(reviewer.getKey())) {
+      Account.Id reviewerId = reviewer.getKey();
+      ReviewerStateInternal reviewerState = reviewer.getValue();
+      // Only add new reviewers to the attention set. Also, don't add the owner because the owner
+      // can only be a "dummy" reviewer for legacy reasons.
+      if (reviewerState.equals(ReviewerStateInternal.REVIEWER)
+          && !currentReviewers.contains(reviewerId)
+          && !reviewerId.equals(getChange().getOwner())) {
         updates.add(
             AttentionSetUpdate.createForWrite(
-                reviewer.getKey(), AttentionSetUpdate.Operation.ADD, "Reviewer was added"));
+                reviewerId, AttentionSetUpdate.Operation.ADD, "Reviewer was added"));
       }
       boolean reviewerRemoved =
-          !reviewer.getValue().equals(ReviewerStateInternal.REVIEWER)
-              && currentReviewers.contains(reviewer.getKey());
-      boolean ccRemoved = reviewer.getValue().equals(ReviewerStateInternal.REMOVED);
+          !reviewerState.equals(ReviewerStateInternal.REVIEWER)
+              && currentReviewers.contains(reviewerId);
+      boolean ccRemoved = reviewerState.equals(ReviewerStateInternal.REMOVED);
       if (reviewerRemoved || ccRemoved) {
         updates.add(
             AttentionSetUpdate.createForWrite(
-                reviewer.getKey(), AttentionSetUpdate.Operation.REMOVE, "Reviewer/Cc was removed"));
+                reviewerId, AttentionSetUpdate.Operation.REMOVE, "Reviewer/Cc was removed"));
       }
     }
     addToPlannedAttentionSetUpdates(updates);
@@ -821,6 +839,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 +882,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/change/PluginDefinedAttributesFactory.java b/java/com/google/gerrit/server/notedb/MissingMetaObjectException.java
similarity index 61%
copy from java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java
copy to java/com/google/gerrit/server/notedb/MissingMetaObjectException.java
index 08d6ce7..ffe9fa8 100644
--- a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java
+++ b/java/com/google/gerrit/server/notedb/MissingMetaObjectException.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2019 The Android Open Source Project
+// Copyright (C) 2021 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,12 +12,11 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.change;
+package com.google.gerrit.server.notedb;
 
-import com.google.gerrit.extensions.common.PluginDefinedInfo;
-import com.google.gerrit.server.query.change.ChangeData;
-import java.util.List;
-
-public interface PluginDefinedAttributesFactory {
-  List<PluginDefinedInfo> create(ChangeData cd);
+/** Separate exception type to throw if requested meta SHA1 is not available. */
+public class MissingMetaObjectException extends RuntimeException {
+  MissingMetaObjectException(String msg) {
+    super(msg);
+  }
 }
diff --git a/java/com/google/gerrit/server/patch/AutoMerger.java b/java/com/google/gerrit/server/patch/AutoMerger.java
index 2e0214c..ac37411 100644
--- a/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -67,6 +67,8 @@
  * is that these refs should never be deleted.
  */
 public class AutoMerger {
+  public static final String AUTO_MERGE_MSG_PREFIX = "Auto-merge of ";
+
   @UsedAt(UsedAt.Project.GOOGLE)
   public static boolean cacheAutomerge(Config cfg) {
     return cfg.getBoolean("change", null, "cacheAutomerge", true);
@@ -107,6 +109,7 @@
               ActionType.GIT_UPDATE,
               "createAutoMerge",
               () -> createAutoMergeCommit(repo, rw, ins, merge, mergeStrategy))
+          .defaultTimeoutMultiplier(2)
           .call();
     } catch (Exception e) {
       Throwables.throwIfUnchecked(e);
@@ -198,7 +201,7 @@
     cb.setAuthor(ident);
     cb.setCommitter(ident);
     cb.setTreeId(tree);
-    cb.setMessage("Auto-merge of " + merge.name() + '\n');
+    cb.setMessage(AUTO_MERGE_MSG_PREFIX + merge.name() + '\n');
     for (RevCommit p : merge.getParents()) {
       cb.addParentId(p);
     }
diff --git a/java/com/google/gerrit/server/patch/BaseCommitUtil.java b/java/com/google/gerrit/server/patch/BaseCommitUtil.java
new file mode 100644
index 0000000..de4a10e
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/BaseCommitUtil.java
@@ -0,0 +1,130 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.InMemoryInserter;
+import com.google.gerrit.server.git.MergeUtil;
+import com.google.inject.Inject;
+import java.io.IOException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.merge.ThreeWayMergeStrategy;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** A utility class for computing the base commit / parent for a specific patchset commit. */
+class BaseCommitUtil {
+  private final AutoMerger autoMerger;
+  private final ThreeWayMergeStrategy mergeStrategy;
+
+  /** If true, auto-merge results are stored in the repository. */
+  private final boolean saveAutomerge;
+
+  private final GitRepositoryManager repoManager;
+
+  @Inject
+  BaseCommitUtil(AutoMerger am, @GerritServerConfig Config cfg, GitRepositoryManager repoManager) {
+    this.autoMerger = am;
+    this.saveAutomerge = AutoMerger.cacheAutomerge(cfg);
+    this.mergeStrategy = MergeUtil.getMergeStrategy(cfg);
+    this.repoManager = repoManager;
+  }
+
+  RevObject getBaseCommit(Project.NameKey project, ObjectId newCommit, @Nullable Integer parentNum)
+      throws IOException {
+    try (Repository repo = repoManager.openRepository(project);
+        ObjectInserter ins = newInserter(repo);
+        ObjectReader reader = ins.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+      return getParentCommit(repo, ins, rw, parentNum, newCommit);
+    }
+  }
+
+  /**
+   * Returns the number of parent commits of the commit represented by the commitId parameter.
+   *
+   * @param project a specific git repository.
+   * @param commitId 20 bytes commitId SHA-1 hash.
+   * @return an integer representing the number of parents of the designated commit.
+   */
+  int getNumParents(Project.NameKey project, ObjectId commitId) throws IOException {
+    try (Repository repo = repoManager.openRepository(project);
+        ObjectInserter ins = repo.newObjectInserter();
+        ObjectReader reader = ins.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+      RevCommit current = rw.parseCommit(commitId);
+      return current.getParentCount();
+    }
+  }
+
+  /**
+   * Returns the parent commit Object of the commit represented by the commitId parameter.
+   *
+   * @param repo a git repository.
+   * @param ins a git object inserter in the database.
+   * @param rw a {@link RevWalk} object of the repository.
+   * @param parentNum used to identify the parent number for merge commits. If parentNum is null and
+   *     {@code commitId} has two parents, the auto-merge commit will be returned. If {@code
+   *     commitId} has a single parent, it will be returned.
+   * @param commitId 20 bytes commitId SHA-1 hash.
+   * @return Returns the parent commit of the commit represented by the commitId parameter. Note
+   *     that auto-merge is not supported for commits having more than two parents.
+   */
+  RevObject getParentCommit(
+      Repository repo,
+      ObjectInserter ins,
+      RevWalk rw,
+      @Nullable Integer parentNum,
+      ObjectId commitId)
+      throws IOException {
+    RevCommit current = rw.parseCommit(commitId);
+    switch (current.getParentCount()) {
+      case 0:
+        return rw.parseAny(emptyTree(ins));
+      case 1:
+        return current.getParent(0);
+      default:
+        if (parentNum != null) {
+          RevCommit r = current.getParent(parentNum - 1);
+          rw.parseBody(r);
+          return r;
+        }
+        // Only support auto-merge for 2 parents, not octopus merges
+        if (current.getParentCount() == 2) {
+          return autoMerger.merge(repo, rw, ins, current, mergeStrategy);
+        }
+        return null;
+    }
+  }
+
+  private ObjectInserter newInserter(Repository repo) {
+    return saveAutomerge ? repo.newObjectInserter() : new InMemoryInserter(repo);
+  }
+
+  private static ObjectId emptyTree(ObjectInserter ins) throws IOException {
+    ObjectId id = ins.insert(Constants.OBJ_TREE, new byte[] {});
+    ins.flush();
+    return id;
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/ComparisonType.java b/java/com/google/gerrit/server/patch/ComparisonType.java
index 260c507..eca2658 100644
--- a/java/com/google/gerrit/server/patch/ComparisonType.java
+++ b/java/com/google/gerrit/server/patch/ComparisonType.java
@@ -16,34 +16,40 @@
 
 import static com.google.gerrit.server.ioutil.BasicSerialization.readVarInt32;
 import static com.google.gerrit.server.ioutil.BasicSerialization.writeVarInt32;
-import static java.util.Objects.requireNonNull;
 
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.server.cache.proto.Cache.FileDiffOutputProto;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
+import java.util.Optional;
 
-public class ComparisonType {
+/** Relation between the old and new commits used in the diff. */
+@AutoValue
+public abstract class ComparisonType {
 
-  /** 1-based parent */
-  private final Integer parentNum;
+  /**
+   * 1-based parent. Available if the old commit is the parent of the new commit and old commit is
+   * not the auto-merge.
+   */
+  abstract Optional<Integer> parentNum();
 
-  private final boolean autoMerge;
+  abstract boolean autoMerge();
 
   public static ComparisonType againstOtherPatchSet() {
-    return new ComparisonType(null, false);
+    return new AutoValue_ComparisonType(Optional.empty(), false);
   }
 
   public static ComparisonType againstParent(int parentNum) {
-    return new ComparisonType(parentNum, false);
+    return new AutoValue_ComparisonType(Optional.of(parentNum), false);
   }
 
   public static ComparisonType againstAutoMerge() {
-    return new ComparisonType(null, true);
+    return new AutoValue_ComparisonType(Optional.empty(), true);
   }
 
-  private ComparisonType(Integer parentNum, boolean autoMerge) {
-    this.parentNum = parentNum;
-    this.autoMerge = autoMerge;
+  private static ComparisonType create(Optional<Integer> parent, boolean automerge) {
+    return new AutoValue_ComparisonType(parent, automerge);
   }
 
   public boolean isAgainstParentOrAutoMerge() {
@@ -51,27 +57,43 @@
   }
 
   public boolean isAgainstParent() {
-    return parentNum != null;
+    return parentNum().isPresent();
   }
 
   public boolean isAgainstAutoMerge() {
-    return autoMerge;
+    return autoMerge();
   }
 
-  public int getParentNum() {
-    requireNonNull(parentNum);
-    return parentNum;
+  public Optional<Integer> getParentNum() {
+    return parentNum();
   }
 
   void writeTo(OutputStream out) throws IOException {
-    writeVarInt32(out, parentNum != null ? parentNum : 0);
-    writeVarInt32(out, autoMerge ? 1 : 0);
+    writeVarInt32(out, isAgainstParent() ? parentNum().get() : 0);
+    writeVarInt32(out, autoMerge() ? 1 : 0);
   }
 
   static ComparisonType readFrom(InputStream in) throws IOException {
     int p = readVarInt32(in);
-    Integer parentNum = p > 0 ? p : null;
+    Optional<Integer> parentNum = p > 0 ? Optional.of(p) : Optional.empty();
     boolean autoMerge = readVarInt32(in) != 0;
-    return new ComparisonType(parentNum, autoMerge);
+    return create(parentNum, autoMerge);
+  }
+
+  public FileDiffOutputProto.ComparisonType toProto() {
+    FileDiffOutputProto.ComparisonType.Builder builder =
+        FileDiffOutputProto.ComparisonType.newBuilder().setAutoMerge(autoMerge());
+    if (parentNum().isPresent()) {
+      builder.setParentNum(parentNum().get());
+    }
+    return builder.build();
+  }
+
+  public static ComparisonType fromProto(FileDiffOutputProto.ComparisonType proto) {
+    Optional<Integer> parentNum = Optional.empty();
+    if (proto.hasField(FileDiffOutputProto.ComparisonType.getDescriptor().findFieldByNumber(1))) {
+      parentNum = Optional.of(proto.getParentNum());
+    }
+    return create(parentNum, proto.getAutoMerge());
   }
 }
diff --git a/java/com/google/gerrit/server/patch/DiffExecutor.java b/java/com/google/gerrit/server/patch/DiffExecutor.java
index 072c2da..63d5c50 100644
--- a/java/com/google/gerrit/server/patch/DiffExecutor.java
+++ b/java/com/google/gerrit/server/patch/DiffExecutor.java
@@ -16,6 +16,7 @@
 
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
+import com.google.gerrit.server.patch.filediff.PatchListLoader;
 import com.google.inject.BindingAnnotation;
 import java.lang.annotation.Retention;
 import java.util.concurrent.ExecutorService;
diff --git a/java/com/google/gerrit/server/patch/DiffMappings.java b/java/com/google/gerrit/server/patch/DiffMappings.java
index 921d66e..57132f8 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.filediff.Edit;
+import com.google.gerrit.server.patch.filediff.FileEdits;
+import java.util.List;
 
 /** Mappings derived from diffs. */
 public class DiffMappings {
@@ -33,31 +38,47 @@
     return Mapping.create(fileMapping, rangeMappings);
   }
 
-  private static FileMapping toFileMapping(PatchListEntry patchListEntry) {
-    switch (patchListEntry.getChangeType()) {
+  public static Mapping toMapping(FileEdits fileEdits) {
+    FileMapping fileMapping = FileMapping.forFile(fileEdits.oldPath(), fileEdits.newPath());
+    ImmutableSet<RangeMapping> rangeMappings = toRangeMappings(fileEdits.edits());
+    return Mapping.create(fileMapping, rangeMappings);
+  }
+
+  private static FileMapping toFileMapping(PatchListEntry ple) {
+    return toFileMapping(ple.getChangeType(), ple.getOldName(), ple.getNewName());
+  }
+
+  private static FileMapping toFileMapping(
+      Patch.ChangeType changeType, String oldName, String newName) {
+    switch (changeType) {
       case ADDED:
-        return FileMapping.forAddedFile(patchListEntry.getNewName());
+        return FileMapping.forAddedFile(newName);
       case MODIFIED:
       case REWRITE:
-        return FileMapping.forModifiedFile(patchListEntry.getNewName());
+        return FileMapping.forModifiedFile(newName);
       case DELETED:
         // Name of deleted file is mentioned as newName.
-        return FileMapping.forDeletedFile(patchListEntry.getNewName());
+        return FileMapping.forDeletedFile(newName);
       case RENAMED:
       case COPIED:
-        return FileMapping.forRenamedFile(patchListEntry.getOldName(), patchListEntry.getNewName());
+        return FileMapping.forRenamedFile(oldName, newName);
       default:
-        throw new IllegalStateException("Unmapped diff type: " + patchListEntry.getChangeType());
+        throw new IllegalStateException("Unmapped diff type: " + changeType);
     }
   }
 
   private static ImmutableSet<RangeMapping> toRangeMappings(PatchListEntry patchListEntry) {
-    return patchListEntry.getEdits().stream()
+    return toRangeMappings(
+        patchListEntry.getEdits().stream().map(Edit::fromJGitEdit).collect(toList()));
+  }
+
+  private static ImmutableSet<RangeMapping> toRangeMappings(List<Edit> edits) {
+    return edits.stream()
         .map(
             edit ->
                 RangeMapping.create(
-                    Range.create(edit.getBeginA(), edit.getEndA()),
-                    Range.create(edit.getBeginB(), edit.getEndB())))
+                    Range.create(edit.beginA(), edit.endA()),
+                    Range.create(edit.beginB(), edit.endB())))
         .collect(toImmutableSet());
   }
 }
diff --git a/java/com/google/gerrit/server/patch/DiffNotAvailableException.java b/java/com/google/gerrit/server/patch/DiffNotAvailableException.java
new file mode 100644
index 0000000..ea92a99
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/DiffNotAvailableException.java
@@ -0,0 +1,38 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+import com.google.gerrit.server.patch.diff.ModifiedFilesCache;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCache;
+
+/**
+ * Thrown by the diff caches - the {@link GitModifiedFilesCache} and the {@link ModifiedFilesCache},
+ * if the implementations failed to retrieve the modified files between the 2 commits.
+ */
+public class DiffNotAvailableException extends Exception {
+  private static final long serialVersionUID = 1L;
+
+  public DiffNotAvailableException(Throwable cause) {
+    super(cause);
+  }
+
+  public DiffNotAvailableException(String message) {
+    super(message);
+  }
+
+  public DiffNotAvailableException(String message, Throwable cause) {
+    super(message, cause);
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/DiffOperations.java b/java/com/google/gerrit/server/patch/DiffOperations.java
new file mode 100644
index 0000000..93aefff
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/DiffOperations.java
@@ -0,0 +1,121 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import java.util.Map;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * An interface for all file diff related operations. Clients should use this interface to request:
+ *
+ * <ul>
+ *   <li>The list of modified files between two commits.
+ *   <li>The list of modified files between a commit and its parent or the auto-merge.
+ *   <li>The detailed file diff for a single file path.
+ *   <li>The Intra-line diffs for a single file path (TODO:ghareeb).
+ * </ul>
+ */
+public interface DiffOperations {
+
+  /**
+   * Returns the list of added, deleted or modified files between a commit against its base. The
+   * {@link Patch#COMMIT_MSG} and {@link Patch#MERGE_LIST} (for merge commits) are also returned.
+   *
+   * <p>If parentNum is set, it is used as the old commit in the diff. Otherwise, if the {@code
+   * newCommit} has only one parent, it is used as base. If {@code newCommit} has two parents, the
+   * auto-merge commit is computed and used as base. The auto-merge for more than two parents is not
+   * supported.
+   *
+   * @param project a project name representing a git repository.
+   * @param newCommit 20 bytes SHA-1 of the new commit used in the diff.
+   * @param parentNum integer specifying which parent to use as base. If null, the only parent will
+   *     be used or the auto-merge if {@code newCommit} is a merge commit.
+   * @return the list of modified files between the two commits.
+   * @throws DiffNotAvailableException if auto-merge is requested for a commit having more than two
+   *     parents, if the {@code newCommit} could not be parsed for extracting the base commit, or if
+   *     an internal error occurred in Git while evaluating the diff.
+   */
+  Map<String, FileDiffOutput> listModifiedFilesAgainstParent(
+      Project.NameKey project, ObjectId newCommit, @Nullable Integer parentNum)
+      throws DiffNotAvailableException;
+
+  /**
+   * Returns the list of added, deleted or modified files between two commits (patchsets). The
+   * commit message and merge list (for merge commits) are also returned.
+   *
+   * @param project a project name representing a git repository.
+   * @param oldCommit 20 bytes SHA-1 of the old commit used in the diff.
+   * @param newCommit 20 bytes SHA-1 of the new commit used in the diff.
+   * @return the list of modified files between the two commits.
+   * @throws DiffNotAvailableException if an internal error occurred in Git while evaluating the
+   *     diff.
+   */
+  Map<String, FileDiffOutput> listModifiedFiles(
+      Project.NameKey project, ObjectId oldCommit, ObjectId newCommit)
+      throws DiffNotAvailableException;
+
+  /**
+   * Returns the diff for a single file between a patchset commit against its parent or the
+   * auto-merge commit. For deleted files, the {@code fileName} parameter should contain the old
+   * name of the file. This method will return {@link FileDiffOutput#empty(String, ObjectId,
+   * ObjectId)} if the requested file identified by {@code fileName} has unchanged content or does
+   * not exist at both commits.
+   *
+   * @param project a project name representing a git repository.
+   * @param newCommit 20 bytes SHA-1 of the new commit used in the diff.
+   * @param parentNum integer specifying which parent to use as base. If null, the only parent will
+   *     be used or the auto-merge if {@code newCommit} is a merge commit.
+   * @param fileName the file name for which the diff should be evaluated.
+   * @param whitespace preference controlling whitespace effect in diff computation.
+   * @return the diff for the single file between the two commits.
+   * @throws DiffNotAvailableException if an internal error occurred in Git while evaluating the
+   *     diff, or if an exception happened while parsing the base commit.
+   */
+  FileDiffOutput getModifiedFileAgainstParent(
+      Project.NameKey project,
+      ObjectId newCommit,
+      @Nullable Integer parentNum,
+      String fileName,
+      @Nullable DiffPreferencesInfo.Whitespace whitespace)
+      throws DiffNotAvailableException;
+
+  /**
+   * Returns the diff for a single file between two patchset commits. For deleted files, the {@code
+   * fileName} parameter should contain the old name of the file. This method will return {@link
+   * FileDiffOutput#empty(String, ObjectId, ObjectId)} if the requested file identified by {@code
+   * fileName} has unchanged content or does not exist at both commits.
+   *
+   * @param project a project name representing a git repository.
+   * @param oldCommit 20 bytes SHA-1 of the old commit used in the diff.
+   * @param newCommit 20 bytes SHA-1 of the new commit used in the diff.
+   * @param fileName the file name for which the diff should be evaluated.
+   * @param whitespace preference controlling whitespace effect in diff computation.
+   * @return the diff for the single file between the two commits.
+   * @throws DiffNotAvailableException if an internal error occurred in Git while evaluating the
+   *     diff.
+   */
+  FileDiffOutput getModifiedFile(
+      Project.NameKey project,
+      ObjectId oldCommit,
+      ObjectId newCommit,
+      String fileName,
+      @Nullable DiffPreferencesInfo.Whitespace whitespace)
+      throws DiffNotAvailableException;
+}
diff --git a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
new file mode 100644
index 0000000..efb64bc
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
@@ -0,0 +1,324 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+import static com.google.gerrit.entities.Patch.COMMIT_MSG;
+import static com.google.gerrit.entities.Patch.MERGE_LIST;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.patch.diff.ModifiedFilesCache;
+import com.google.gerrit.server.patch.diff.ModifiedFilesCacheImpl;
+import com.google.gerrit.server.patch.diff.ModifiedFilesCacheKey;
+import com.google.gerrit.server.patch.filediff.FileDiffCache;
+import com.google.gerrit.server.patch.filediff.FileDiffCacheImpl;
+import com.google.gerrit.server.patch.filediff.FileDiffCacheKey;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheImpl;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl.DiffAlgorithm;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * Provides different file diff operations. Uses the underlying Git/Gerrit caches to speed up the
+ * diff computation.
+ */
+public class DiffOperationsImpl implements DiffOperations {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private static final int RENAME_SCORE = 60;
+  private static final DiffAlgorithm DEFAULT_DIFF_ALGORITHM = DiffAlgorithm.HISTOGRAM;
+  private static final Whitespace DEFAULT_WHITESPACE = Whitespace.IGNORE_NONE;
+
+  private final ModifiedFilesCache modifiedFilesCache;
+  private final FileDiffCache fileDiffCache;
+  private final BaseCommitUtil baseCommitUtil;
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        bind(DiffOperations.class).to(DiffOperationsImpl.class);
+        install(GitModifiedFilesCacheImpl.module());
+        install(ModifiedFilesCacheImpl.module());
+        install(GitFileDiffCacheImpl.module());
+        install(FileDiffCacheImpl.module());
+      }
+    };
+  }
+
+  @Inject
+  public DiffOperationsImpl(
+      ModifiedFilesCache modifiedFilesCache,
+      FileDiffCache fileDiffCache,
+      BaseCommitUtil baseCommit) {
+    this.modifiedFilesCache = modifiedFilesCache;
+    this.fileDiffCache = fileDiffCache;
+    this.baseCommitUtil = baseCommit;
+  }
+
+  @Override
+  public Map<String, FileDiffOutput> listModifiedFilesAgainstParent(
+      Project.NameKey project, ObjectId newCommit, @Nullable Integer parent)
+      throws DiffNotAvailableException {
+    try {
+      DiffParameters diffParams = computeDiffParameters(project, newCommit, parent);
+      return getModifiedFiles(project, newCommit, diffParams);
+    } catch (IOException e) {
+      throw new DiffNotAvailableException(
+          "Failed to evaluate the parent/base commit for commit " + newCommit, e);
+    }
+  }
+
+  @Override
+  public Map<String, FileDiffOutput> listModifiedFiles(
+      Project.NameKey project, ObjectId oldCommit, ObjectId newCommit)
+      throws DiffNotAvailableException {
+    DiffParameters params =
+        DiffParameters.builder()
+            .project(project)
+            .newCommit(newCommit)
+            .baseCommit(oldCommit)
+            .comparisonType(ComparisonType.againstOtherPatchSet())
+            .build();
+    return getModifiedFiles(project, newCommit, params);
+  }
+
+  @Override
+  public FileDiffOutput getModifiedFileAgainstParent(
+      Project.NameKey project,
+      ObjectId newCommit,
+      @Nullable Integer parent,
+      String fileName,
+      @Nullable DiffPreferencesInfo.Whitespace whitespace)
+      throws DiffNotAvailableException {
+    try {
+      DiffParameters diffParams = computeDiffParameters(project, newCommit, parent);
+      FileDiffCacheKey key =
+          createFileDiffCacheKey(project, diffParams.baseCommit(), newCommit, fileName, whitespace);
+      Map<String, FileDiffOutput> result = getModifiedFilesForKeys(ImmutableList.of(key));
+      return result.containsKey(fileName)
+          ? result.get(fileName)
+          : FileDiffOutput.empty(fileName, key.oldCommit(), key.newCommit());
+    } catch (IOException e) {
+      throw new DiffNotAvailableException(
+          "Failed to evaluate the parent/base commit for commit " + newCommit, e);
+    }
+  }
+
+  @Override
+  public FileDiffOutput getModifiedFile(
+      Project.NameKey project,
+      ObjectId oldCommit,
+      ObjectId newCommit,
+      String fileName,
+      @Nullable DiffPreferencesInfo.Whitespace whitespace)
+      throws DiffNotAvailableException {
+    FileDiffCacheKey key =
+        createFileDiffCacheKey(project, oldCommit, newCommit, fileName, whitespace);
+    Map<String, FileDiffOutput> result = getModifiedFilesForKeys(ImmutableList.of(key));
+    return result.containsKey(fileName)
+        ? result.get(fileName)
+        : FileDiffOutput.empty(fileName, oldCommit, newCommit);
+  }
+
+  private Map<String, FileDiffOutput> getModifiedFiles(
+      Project.NameKey project, ObjectId newCommit, DiffParameters diffParams)
+      throws DiffNotAvailableException {
+    try {
+      ObjectId oldCommit = diffParams.baseCommit();
+      ComparisonType cmp = diffParams.comparisonType();
+
+      ImmutableList<ModifiedFile> modifiedFiles =
+          modifiedFilesCache.get(createModifiedFilesKey(project, oldCommit, newCommit));
+
+      List<FileDiffCacheKey> fileCacheKeys = new ArrayList<>();
+      fileCacheKeys.add(
+          createFileDiffCacheKey(
+              project, oldCommit, newCommit, COMMIT_MSG, /* whitespace= */ null));
+
+      if (cmp.isAgainstAutoMerge() || isMergeAgainstParent(cmp, project, newCommit)) {
+        fileCacheKeys.add(
+            createFileDiffCacheKey(
+                project, oldCommit, newCommit, MERGE_LIST, /*whitespace = */ null));
+      }
+
+      if (diffParams.skipFiles() == null) {
+        modifiedFiles.stream()
+            .map(
+                entity ->
+                    createFileDiffCacheKey(
+                        project,
+                        oldCommit,
+                        newCommit,
+                        entity.newPath().isPresent()
+                            ? entity.newPath().get()
+                            : entity.oldPath().get(),
+                        /* whitespace= */ null))
+            .forEach(fileCacheKeys::add);
+      }
+      return getModifiedFilesForKeys(fileCacheKeys);
+    } catch (IOException e) {
+      throw new DiffNotAvailableException(e);
+    }
+  }
+
+  private Map<String, FileDiffOutput> getModifiedFilesForKeys(List<FileDiffCacheKey> keys)
+      throws DiffNotAvailableException {
+    ImmutableMap.Builder<String, FileDiffOutput> files = ImmutableMap.builder();
+    ImmutableMap<FileDiffCacheKey, FileDiffOutput> fileDiffs = fileDiffCache.getAll(keys);
+
+    for (FileDiffOutput fileDiffOutput : fileDiffs.values()) {
+      if (fileDiffOutput.isEmpty() || allDueToRebase(fileDiffOutput)) {
+        continue;
+      }
+      if (fileDiffOutput.changeType() == ChangeType.DELETED) {
+        files.put(fileDiffOutput.oldPath().get(), fileDiffOutput);
+      } else {
+        files.put(fileDiffOutput.newPath().get(), fileDiffOutput);
+      }
+    }
+    return files.build();
+  }
+
+  private static boolean allDueToRebase(FileDiffOutput fileDiffOutput) {
+    return fileDiffOutput.allEditsDueToRebase()
+        && (!(fileDiffOutput.changeType() == ChangeType.RENAMED
+            || fileDiffOutput.changeType() == ChangeType.COPIED));
+  }
+
+  private boolean isMergeAgainstParent(ComparisonType cmp, Project.NameKey project, ObjectId commit)
+      throws IOException {
+    return (cmp.isAgainstParent() && baseCommitUtil.getNumParents(project, commit) > 1);
+  }
+
+  private static ModifiedFilesCacheKey createModifiedFilesKey(
+      Project.NameKey project, ObjectId aCommit, ObjectId bCommit) {
+    return ModifiedFilesCacheKey.builder()
+        .project(project)
+        .aCommit(aCommit)
+        .bCommit(bCommit)
+        .renameScore(RENAME_SCORE)
+        .build();
+  }
+
+  private static FileDiffCacheKey createFileDiffCacheKey(
+      Project.NameKey project,
+      ObjectId aCommit,
+      ObjectId bCommit,
+      String newPath,
+      @Nullable Whitespace whitespace) {
+    whitespace = whitespace == null ? DEFAULT_WHITESPACE : whitespace;
+    return FileDiffCacheKey.builder()
+        .project(project)
+        .oldCommit(aCommit)
+        .newCommit(bCommit)
+        .newFilePath(newPath)
+        .renameScore(RENAME_SCORE)
+        .diffAlgorithm(DEFAULT_DIFF_ALGORITHM)
+        .whitespace(whitespace)
+        .build();
+  }
+
+  @AutoValue
+  abstract static class DiffParameters {
+    abstract Project.NameKey project();
+
+    abstract ObjectId newCommit();
+
+    abstract ObjectId baseCommit();
+
+    abstract ComparisonType comparisonType();
+
+    @Nullable
+    abstract Integer parent();
+
+    /** Compute the diff for {@value Patch#COMMIT_MSG} and {@link Patch#MERGE_LIST} only. */
+    @Nullable
+    abstract Boolean skipFiles();
+
+    static Builder builder() {
+      return new AutoValue_DiffOperationsImpl_DiffParameters.Builder();
+    }
+
+    @AutoValue.Builder
+    abstract static class Builder {
+
+      abstract Builder project(Project.NameKey project);
+
+      abstract Builder newCommit(ObjectId newCommit);
+
+      abstract Builder baseCommit(ObjectId baseCommit);
+
+      abstract Builder parent(@Nullable Integer parent);
+
+      abstract Builder skipFiles(@Nullable Boolean skipFiles);
+
+      abstract Builder comparisonType(ComparisonType comparisonType);
+
+      public abstract DiffParameters build();
+    }
+  }
+
+  /** Compute Diff parameters - the base commit and the comparison type - using the input args. */
+  private DiffParameters computeDiffParameters(
+      Project.NameKey project, ObjectId newCommit, Integer parent) throws IOException {
+    DiffParameters.Builder result =
+        DiffParameters.builder().project(project).newCommit(newCommit).parent(parent);
+    if (parent != null) {
+      result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, parent));
+      result.comparisonType(ComparisonType.againstParent(parent));
+      return result.build();
+    }
+    int numParents = baseCommitUtil.getNumParents(project, newCommit);
+    if (numParents == 1) {
+      result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, parent));
+      result.comparisonType(ComparisonType.againstParent(1));
+      return result.build();
+    }
+    if (numParents > 2) {
+      logger.atFine().log(
+          "Diff against auto-merge for merge commits "
+              + "with more than two parents is not supported. Commit "
+              + newCommit
+              + " has "
+              + numParents
+              + " parents. Falling back to the diff against the first parent.");
+      result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, 1).getId());
+      result.comparisonType(ComparisonType.againstParent(1));
+      result.skipFiles(true);
+    } else {
+      result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, null));
+      result.comparisonType(ComparisonType.againstAutoMerge());
+    }
+    return result.build();
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/DiffUtil.java b/java/com/google/gerrit/server/patch/DiffUtil.java
new file mode 100644
index 0000000..1e88f9f
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/DiffUtil.java
@@ -0,0 +1,89 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+package com.google.gerrit.server.patch;
+
+import com.google.gerrit.server.patch.diff.ModifiedFilesCache;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCache;
+import java.io.IOException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * A utility class used by the diff cache interfaces {@link GitModifiedFilesCache} and {@link
+ * ModifiedFilesCache}.
+ */
+public class DiffUtil {
+
+  /**
+   * Returns the Git tree object ID pointed to by the commitId parameter.
+   *
+   * @param rw a {@link RevWalk} of an opened repository that is used to walk the commit graph.
+   * @param commitId 20 bytes commitId SHA-1 hash.
+   * @return Git tree object ID pointed to by the commitId.
+   */
+  public static ObjectId getTreeId(RevWalk rw, ObjectId commitId) throws IOException {
+    RevCommit current = rw.parseCommit(commitId);
+    return current.getTree().getId();
+  }
+
+  /**
+   * Returns the RevCommit object given the 20 bytes commitId SHA-1 hash.
+   *
+   * @param rw a {@link RevWalk} of an opened repository that is used to walk the commit graph.
+   * @param commitId 20 bytes commitId SHA-1 hash
+   * @return The RevCommit representing the commit in Git
+   * @throws IOException a pack file or loose object could not be read while parsing the commits.
+   */
+  public static RevCommit getRevCommit(RevWalk rw, ObjectId commitId) throws IOException {
+    return rw.parseCommit(commitId);
+  }
+
+  /**
+   * Returns true if the commitA and commitB parameters are parent/child, if they have a common
+   * parent, or if any of them is a root or merge commit.
+   */
+  public static boolean areRelated(RevCommit commitA, RevCommit commitB) {
+    return commitA == null
+        || isRootOrMergeCommit(commitA)
+        || isRootOrMergeCommit(commitB)
+        || areParentAndChild(commitA, commitB)
+        || haveCommonParent(commitA, commitB);
+  }
+
+  public static int stringSize(String str) {
+    if (str != null) {
+      // each character in the string occupies 2 bytes. Ignoring the fixed overhead for the string
+      // (length, offset and hash code) since they are negligible and do not affect the comparison
+      // of 2 strings.
+      return str.length() * 2;
+    }
+    return 0;
+  }
+
+  private static boolean isRootOrMergeCommit(RevCommit commit) {
+    return commit.getParentCount() != 1;
+  }
+
+  private static boolean areParentAndChild(RevCommit commitA, RevCommit commitB) {
+    return ObjectId.isEqual(commitA.getParent(0), commitB)
+        || ObjectId.isEqual(commitB.getParent(0), commitA);
+  }
+
+  private static boolean haveCommonParent(RevCommit commitA, RevCommit commitB) {
+    return ObjectId.isEqual(commitA.getParent(0), commitB.getParent(0));
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/GitPositionTransformer.java b/java/com/google/gerrit/server/patch/GitPositionTransformer.java
index d890bc2..f33d302 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);
+    }
   }
 
   /**
@@ -514,6 +519,15 @@
     }
 
     /**
+     * Returns the original underlying entity.
+     *
+     * @return the original instance of {@code T}
+     */
+    public T getEntity() {
+      return entity;
+    }
+
+    /**
      * Returns an updated version of the entity to which the internally stored {@link Position} was
      * written back to.
      *
diff --git a/java/com/google/gerrit/server/patch/MagicFile.java b/java/com/google/gerrit/server/patch/MagicFile.java
index aa6b11f..e42dd8c 100644
--- a/java/com/google/gerrit/server/patch/MagicFile.java
+++ b/java/com/google/gerrit/server/patch/MagicFile.java
@@ -93,7 +93,7 @@
           }
         default:
           int uninterestingParent =
-              comparisonType.isAgainstParent() ? comparisonType.getParentNum() : 1;
+              comparisonType.isAgainstParent() ? comparisonType.getParentNum().get() : 1;
 
           b.append("Merge List:\n\n");
           for (RevCommit commit : MergeListBuilder.build(rw, c, uninterestingParent)) {
diff --git a/java/com/google/gerrit/server/patch/PatchList.java b/java/com/google/gerrit/server/patch/PatchList.java
index 28f61d3..cb95553 100644
--- a/java/com/google/gerrit/server/patch/PatchList.java
+++ b/java/com/google/gerrit/server/patch/PatchList.java
@@ -24,8 +24,10 @@
 import static org.eclipse.jgit.lib.ObjectIdSerializer.writeWithoutMarker;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.git.ObjectIds;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
@@ -49,8 +51,42 @@
   static final Comparator<String> FILE_PATH_CMP =
       Comparator.comparing(Patch::isMagic).reversed().thenComparing(Comparator.naturalOrder());
 
+  /**
+   * We use the ChangeType comparator for a rare case when PatchList contains two entries for the
+   * same file, e.g. {ADDED, DELETED}. We return a single entry according to the following order.
+   * Check the following bug for an example case:
+   * https://bugs.chromium.org/p/gerrit/issues/detail?id=13914.
+   */
+  @VisibleForTesting
+  static class ChangeTypeCmp implements Comparator<ChangeType> {
+    static final List<ChangeType> order =
+        ImmutableList.of(
+            ChangeType.ADDED,
+            ChangeType.RENAMED,
+            ChangeType.MODIFIED,
+            ChangeType.COPIED,
+            ChangeType.REWRITE,
+            ChangeType.DELETED);
+
+    @Override
+    public int compare(ChangeType o1, ChangeType o2) {
+      int idx1 = priority(o1);
+      int idx2 = priority(o2);
+      return idx1 - idx2;
+    }
+
+    private int priority(ChangeType changeType) {
+      int idx = order.indexOf(changeType);
+      // Return least priority if the element is not in the order list.
+      return idx == -1 ? order.size() : idx;
+    }
+  }
+
+  @VisibleForTesting static final Comparator<ChangeType> CHANGE_TYPE_CMP = new ChangeTypeCmp();
+
   private static final Comparator<PatchListEntry> PATCH_CMP =
-      Comparator.comparing(PatchListEntry::getNewName, FILE_PATH_CMP);
+      Comparator.comparing(PatchListEntry::getNewName, FILE_PATH_CMP)
+          .thenComparing(PatchListEntry::getChangeType, CHANGE_TYPE_CMP);
 
   @Nullable private transient ObjectId oldId;
   private transient ObjectId newId;
@@ -121,12 +157,25 @@
 
   /** Find an entry by name, returning an empty entry if not present. */
   public PatchListEntry get(String fileName) {
-    final int index = search(fileName);
-    return 0 <= index ? patches[index] : PatchListEntry.empty(fileName);
+    int index = search(fileName);
+    if (index >= 0) {
+      return patches[index];
+    }
+    // If index is negative, it marks the insertion point of the object in the list.
+    // index = (-(insertion point) - 1).
+    // Since we use the ChangeType in the comparison, the object that we are using in the lookup
+    // (which has a ADDED ChangeType) may have a different ChangeType than the object in the list.
+    // For this reason, we look at the file name of the object at the insertion point and return it
+    // if it has the same name.
+    index = -1 * (index + 1);
+    if (index < patches.length && patches[index].getNewName().equals(fileName)) {
+      return patches[index];
+    }
+    return PatchListEntry.empty(fileName);
   }
 
   private int search(String fileName) {
-    PatchListEntry want = PatchListEntry.empty(fileName);
+    PatchListEntry want = PatchListEntry.empty(fileName, ChangeType.ADDED);
     return Arrays.binarySearch(patches, 0, patches.length, want, PATCH_CMP);
   }
 
diff --git a/java/com/google/gerrit/server/patch/PatchListCacheImpl.java b/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
index b663b9d..a3e9a54 100644
--- a/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.server.cache.CacheBackend;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.patch.filediff.PatchListLoader;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
@@ -37,7 +38,7 @@
 /** Provides a cached list of {@link PatchListEntry}. */
 @Singleton
 public class PatchListCacheImpl implements PatchListCache {
-  static final String FILE_NAME = "diff";
+  public static final String FILE_NAME = "diff";
   static final String INTRA_NAME = "diff_intraline";
   static final String DIFF_SUMMARY = "diff_summary";
 
diff --git a/java/com/google/gerrit/server/patch/PatchListEntry.java b/java/com/google/gerrit/server/patch/PatchListEntry.java
index c91355a..de292c3 100644
--- a/java/com/google/gerrit/server/patch/PatchListEntry.java
+++ b/java/com/google/gerrit/server/patch/PatchListEntry.java
@@ -49,8 +49,12 @@
   private static final byte[] EMPTY_HEADER = {};
 
   static PatchListEntry empty(String fileName) {
+    return empty(fileName, ChangeType.MODIFIED);
+  }
+
+  static PatchListEntry empty(String fileName, ChangeType changeType) {
     return new PatchListEntry(
-        ChangeType.MODIFIED,
+        changeType,
         PatchType.UNIFIED,
         null,
         fileName,
@@ -77,7 +81,7 @@
   // Note: When adding new fields, the serialVersionUID in PatchListKey must be
   // incremented so that entries from the cache are automatically invalidated.
 
-  PatchListEntry(
+  public PatchListEntry(
       FileHeader hdr, List<Edit> editList, Set<Edit> editsDueToRebase, long size, long sizeDelta) {
     changeType = toChangeType(hdr);
     patchType = toPatchType(hdr);
diff --git a/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
index 02f46df..826198f 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptFactory.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditUtil;
@@ -64,14 +65,16 @@
         String fileName,
         @Assisted("patchSetA") PatchSet.Id patchSetA,
         @Assisted("patchSetB") PatchSet.Id patchSetB,
-        DiffPreferencesInfo diffPrefs);
+        DiffPreferencesInfo diffPrefs,
+        CurrentUser currentUser);
 
     PatchScriptFactory create(
         ChangeNotes notes,
         String fileName,
         int parentNum,
         PatchSet.Id patchSetB,
-        DiffPreferencesInfo diffPrefs);
+        DiffPreferencesInfo diffPrefs,
+        CurrentUser currentUser);
   }
 
   private final GitRepositoryManager repoManager;
@@ -84,6 +87,8 @@
   private final int parentNum;
   private final PatchSet.Id psb;
   private final DiffPreferencesInfo diffPrefs;
+  private final CurrentUser currentUser;
+
   private final ChangeEditUtil editReader;
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
@@ -105,7 +110,8 @@
       @Assisted String fileName,
       @Assisted("patchSetA") @Nullable PatchSet.Id patchSetA,
       @Assisted("patchSetB") PatchSet.Id patchSetB,
-      @Assisted DiffPreferencesInfo diffPrefs) {
+      @Assisted DiffPreferencesInfo diffPrefs,
+      @Assisted CurrentUser currentUser) {
     this.repoManager = grm;
     this.psUtil = psUtil;
     this.builderFactory = builderFactory;
@@ -120,6 +126,7 @@
     this.parentNum = -1;
     this.psb = patchSetB;
     this.diffPrefs = diffPrefs;
+    this.currentUser = currentUser;
 
     changeId = patchSetB.changeId();
   }
@@ -137,7 +144,8 @@
       @Assisted String fileName,
       @Assisted int parentNum,
       @Assisted PatchSet.Id patchSetB,
-      @Assisted DiffPreferencesInfo diffPrefs) {
+      @Assisted DiffPreferencesInfo diffPrefs,
+      @Assisted CurrentUser currentUser) {
     this.repoManager = grm;
     this.psUtil = psUtil;
     this.builderFactory = builderFactory;
@@ -152,6 +160,7 @@
     this.parentNum = parentNum;
     this.psb = patchSetB;
     this.diffPrefs = diffPrefs;
+    this.currentUser = currentUser;
 
     changeId = patchSetB.changeId();
     checkArgument(parentNum >= 0, "parentNum must be >= 0");
@@ -163,7 +172,7 @@
           PermissionBackendException {
 
     try {
-      permissionBackend.currentUser().change(notes).check(ChangePermission.READ);
+      permissionBackend.user(currentUser).change(notes).check(ChangePermission.READ);
     } catch (AuthException e) {
       throw new NoSuchChangeException(changeId, e);
     }
diff --git a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
new file mode 100644
index 0000000..18d532b
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
@@ -0,0 +1,278 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
+import com.google.gerrit.common.data.PatchScript;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.prettify.common.SparseFileContent;
+import com.google.gerrit.prettify.common.SparseFileContent.Accessor;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.LargeObjectException;
+import com.google.gerrit.server.git.validators.CommentCumulativeSizeValidator;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.diff.Edit;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * This class is used on submit to compute the diff between the latest approved patch-set, and the
+ * current submitted patch-set.
+ *
+ * <p>Latest approved patch-set is defined by the latest patch-set which has Code-Review label voted
+ * with the maximum possible value.
+ *
+ * <p>If the latest approved patch-set is the same as the submitted patch-set, the diff will be
+ * empty.
+ *
+ * <p>We exclude the magic files from the returned diff to make it shorter and more concise.
+ */
+public class SubmitWithStickyApprovalDiff {
+  private final ProjectCache projectCache;
+  private final PatchScriptFactory.Factory patchScriptFactoryFactory;
+  private final PatchListCache patchListCache;
+  private final int maxCumulativeSize;
+
+  @Inject
+  SubmitWithStickyApprovalDiff(
+      ProjectCache projectCache,
+      PatchScriptFactory.Factory patchScriptFactoryFactory,
+      PatchListCache patchListCache,
+      @GerritServerConfig Config serverConfig) {
+    this.projectCache = projectCache;
+    this.patchScriptFactoryFactory = patchScriptFactoryFactory;
+    this.patchListCache = patchListCache;
+    maxCumulativeSize =
+        serverConfig.getInt(
+            "change",
+            "cumulativeCommentSizeLimit",
+            CommentCumulativeSizeValidator.DEFAULT_CUMULATIVE_COMMENT_SIZE_LIMIT);
+  }
+
+  public String apply(ChangeNotes notes, CurrentUser currentUser)
+      throws AuthException, IOException, PermissionBackendException,
+          InvalidChangeOperationException {
+    // In some submit strategies, the current patch-set doesn't exist yet as it's being created
+    // during the submit. Hence, we assign the current patch-set to be the last existing patch-set.
+    PatchSet currentPatchset =
+        notes.getPatchSets().values().stream()
+            .max((p1, p2) -> p1.id().get() - p2.id().get())
+            .orElseThrow(
+                () ->
+                    new IllegalStateException(
+                        String.format(
+                            "change %s can't load any patchset", notes.getChangeId().toString())));
+
+    PatchSet.Id latestApprovedPatchsetId = getLatestApprovedPatchsetId(notes);
+    if (latestApprovedPatchsetId.get() == currentPatchset.id().get()) {
+      // If the latest approved patchset is the current patchset, no need to return anything.
+      return "";
+    }
+    StringBuilder diff =
+        new StringBuilder(
+            String.format(
+                "\n\n%d is the latest approved patch-set.\n", latestApprovedPatchsetId.get()));
+    PatchList patchList =
+        getPatchList(
+            notes.getProjectName(),
+            currentPatchset,
+            notes.getPatchSets().get(latestApprovedPatchsetId));
+
+    // To make the message a bit more concise, we skip the magic files.
+    List<PatchListEntry> patchListEntryList =
+        patchList.getPatches().stream()
+            .filter(p -> !Patch.isMagic(p.getNewName()))
+            .collect(Collectors.toList());
+
+    if (patchListEntryList.isEmpty()) {
+      diff.append(
+          "No files were changed between the latest approved patch-set and the submitted one.\n");
+      return diff.toString();
+    }
+
+    diff.append("The change was submitted with unreviewed changes in the following files:\n\n");
+
+    for (PatchListEntry patchListEntry : patchListEntryList) {
+      diff.append(
+          getDiffForFile(
+              notes, currentPatchset.id(), latestApprovedPatchsetId, patchListEntry, currentUser));
+    }
+    if (diff.length() > maxCumulativeSize) {
+      // The diff length is not counted as part of the limit (for technical reasons, since we'd
+      // have to call CommentCumulativeSizeValidator), but it's best not to post an extra large
+      // change message here.
+      return String.format(
+          "\n\n%d is the latest approved patch-set.\nThe change was submitted "
+              + "with many unreviewed changes (the diff is too large to show). Please review the "
+              + "diff.",
+          latestApprovedPatchsetId.get());
+    }
+    return diff.toString();
+  }
+
+  private String getDiffForFile(
+      ChangeNotes notes,
+      PatchSet.Id currentPatchsetId,
+      PatchSet.Id latestApprovedPatchsetId,
+      PatchListEntry patchListEntry,
+      CurrentUser currentUser)
+      throws AuthException, InvalidChangeOperationException, IOException,
+          PermissionBackendException {
+    StringBuilder diff =
+        new StringBuilder(
+            String.format(
+                "The name of the file: %s\nInsertions: %d, Deletions: %d.\n\n",
+                patchListEntry.getNewName(),
+                patchListEntry.getInsertions(),
+                patchListEntry.getDeletions()));
+    DiffPreferencesInfo diffPreferencesInfo = createDefaultDiffPreferencesInfo();
+    PatchScriptFactory patchScriptFactory =
+        patchScriptFactoryFactory.create(
+            notes,
+            patchListEntry.getNewName(),
+            latestApprovedPatchsetId,
+            currentPatchsetId,
+            diffPreferencesInfo,
+            currentUser);
+    PatchScript patchScript = null;
+    try {
+      patchScript = patchScriptFactory.call();
+    } catch (LargeObjectException exception) {
+      diff.append("The file content is too large for showing the full diff. \n\n");
+      return diff.toString();
+    }
+    if (patchScript.getChangeType() == ChangeType.RENAMED) {
+      diff.append(
+          String.format(
+              "The file %s was renamed to %s\n",
+              patchListEntry.getOldName(), patchListEntry.getNewName()));
+    }
+    SparseFileContent.Accessor fileA = patchScript.getA().createAccessor();
+    SparseFileContent.Accessor fileB = patchScript.getB().createAccessor();
+    boolean editsExist = false;
+    if (patchScript.getEdits().stream().anyMatch(e -> e.getType() != Edit.Type.EMPTY)) {
+      diff.append("```\n");
+      editsExist = true;
+    }
+    for (Edit edit : patchScript.getEdits()) {
+      diff.append(getDiffForEdit(fileA, fileB, edit));
+    }
+    if (editsExist) {
+      diff.append("```\n");
+    }
+    return diff.toString();
+  }
+
+  private String getDiffForEdit(Accessor fileA, Accessor fileB, Edit edit) {
+    StringBuilder diff = new StringBuilder();
+    Edit.Type type = edit.getType();
+    switch (type) {
+      case INSERT:
+        diff.append(String.format("@@ +%d:%d @@\n", edit.getBeginB(), edit.getEndB()));
+        diff.append(getModifiedLines(fileB, edit.getBeginB(), edit.getEndB(), '+'));
+        diff.append("\n");
+        break;
+      case DELETE:
+        diff.append(String.format("@@ -%d:%d @@\n", edit.getBeginA(), edit.getEndA()));
+        diff.append(getModifiedLines(fileA, edit.getBeginA(), edit.getEndA(), '-'));
+        diff.append("\n");
+        break;
+      case REPLACE:
+        diff.append(
+            String.format(
+                "@@ -%d:%d, +%d:%d @@\n",
+                edit.getBeginA(), edit.getEndA(), edit.getBeginB(), edit.getEndB()));
+        diff.append(getModifiedLines(fileA, edit.getBeginA(), edit.getEndA(), '-'));
+        diff.append(getModifiedLines(fileB, edit.getBeginB(), edit.getEndB(), '+'));
+        diff.append("\n");
+        break;
+      case EMPTY:
+        // do nothing since there is no change here.
+    }
+    return diff.toString();
+  }
+
+  private String getModifiedLines(Accessor file, int begin, int end, char modificationType) {
+    StringBuilder diff = new StringBuilder();
+    for (int i = begin; i < end; i++) {
+      diff.append(String.format("%c  %s\n", modificationType, file.get(i)));
+    }
+    return diff.toString();
+  }
+
+  private DiffPreferencesInfo createDefaultDiffPreferencesInfo() {
+    DiffPreferencesInfo diffPreferencesInfo = new DiffPreferencesInfo();
+    diffPreferencesInfo.ignoreWhitespace = Whitespace.IGNORE_NONE;
+    diffPreferencesInfo.intralineDifference = true;
+    return diffPreferencesInfo;
+  }
+
+  private PatchSet.Id getLatestApprovedPatchsetId(ChangeNotes notes) {
+    ProjectState projectState =
+        projectCache.get(notes.getProjectName()).orElseThrow(illegalState(notes.getProjectName()));
+    PatchSet.Id maxPatchSetId = PatchSet.id(notes.getChangeId(), 1);
+    for (PatchSetApproval patchSetApproval : notes.getApprovals().values()) {
+      if (!patchSetApproval.label().equals(LabelId.CODE_REVIEW)) {
+        continue;
+      }
+      if (!projectState
+          .getLabelTypes(notes)
+          .byLabel(patchSetApproval.labelId())
+          .isMaxPositive(patchSetApproval)) {
+        continue;
+      }
+      if (patchSetApproval.patchSetId().get() > maxPatchSetId.get()) {
+        maxPatchSetId = patchSetApproval.patchSetId();
+      }
+    }
+    return maxPatchSetId;
+  }
+
+  /**
+   * Gets the {@link PatchList} between the two latest patch-sets. Can be used to compute difference
+   * in files between those two patch-sets .
+   */
+  private PatchList getPatchList(Project.NameKey project, PatchSet ps, PatchSet priorPatchSet) {
+    PatchListKey key =
+        PatchListKey.againstCommit(
+            priorPatchSet.commitId(), ps.commitId(), DiffPreferencesInfo.Whitespace.IGNORE_NONE);
+    try {
+      return patchListCache.get(key, project);
+    } catch (PatchListNotAvailableException ex) {
+      throw new StorageException(
+          "failed to compute difference in files, so won't post diff messsage on submit although "
+              + "the latest approved patch-set was not the same as the submitted patch-set.",
+          ex);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java
new file mode 100644
index 0000000..bcae238
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java
@@ -0,0 +1,45 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch.diff;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCache;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheImpl;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheKey;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
+
+/**
+ * A cache for the list of Git modified files between 2 commits (patchsets) with extra Gerrit logic.
+ *
+ * <p>The loader uses the underlying {@link GitModifiedFilesCacheImpl} to retrieve the git modified
+ * files.
+ *
+ * <p>If the {@link ModifiedFilesCacheKey#aCommit()} is equal to {@link
+ * org.eclipse.jgit.lib.Constants#EMPTY_TREE_ID}, the diff will be evaluated against the empty tree,
+ * and the result will be exactly the same as the caller can get from {@link
+ * GitModifiedFilesCache#get(GitModifiedFilesCacheKey)}
+ */
+public interface ModifiedFilesCache {
+
+  /**
+   * @param key used to identify two git commits and contains other attributes to control the diff
+   *     calculation.
+   * @return the list of {@link ModifiedFile}s between the 2 git commits identified by the key.
+   * @throws DiffNotAvailableException the supplied commits IDs of the key do no exist, are not IDs
+   *     of a commit, or an exception occurred while reading a pack file.
+   */
+  ImmutableList<ModifiedFile> get(ModifiedFilesCacheKey key) throws DiffNotAvailableException;
+}
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
new file mode 100644
index 0000000..6023c0e
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
@@ -0,0 +1,206 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch.diff;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Streams;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffUtil;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCache;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheImpl;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheKey;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.TypeLiteral;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Stream;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * A cache for the list of Git modified files between 2 commits (patchsets) with extra Gerrit logic.
+ *
+ * <p>The loader of this cache wraps a {@link GitModifiedFilesCache} to retrieve the git modified
+ * files.
+ *
+ * <p>If the {@link ModifiedFilesCacheKey#aCommit()} is equal to {@link
+ * org.eclipse.jgit.lib.Constants#EMPTY_TREE_ID}, the diff will be evaluated against the empty tree,
+ * and the result will be exactly the same as the caller can get from {@link
+ * GitModifiedFilesCache#get(GitModifiedFilesCacheKey)}
+ */
+public class ModifiedFilesCacheImpl implements ModifiedFilesCache {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private static final String MODIFIED_FILES = "modified_files";
+
+  private final LoadingCache<ModifiedFilesCacheKey, ImmutableList<ModifiedFile>> cache;
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        bind(ModifiedFilesCache.class).to(ModifiedFilesCacheImpl.class);
+
+        // The documentation has some defaults and recommendations for setting the cache
+        // attributes:
+        // https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#cache.
+        // The cache is using the default disk limit as per section cache.<name>.diskLimit
+        // in the cache documentation link.
+        persist(
+                ModifiedFilesCacheImpl.MODIFIED_FILES,
+                ModifiedFilesCacheKey.class,
+                new TypeLiteral<ImmutableList<ModifiedFile>>() {})
+            .keySerializer(ModifiedFilesCacheKey.Serializer.INSTANCE)
+            .valueSerializer(GitModifiedFilesCacheImpl.ValueSerializer.INSTANCE)
+            .maximumWeight(10 << 20)
+            .weigher(ModifiedFilesWeigher.class)
+            .version(1)
+            .loader(ModifiedFilesLoader.class);
+      }
+    };
+  }
+
+  @Inject
+  public ModifiedFilesCacheImpl(
+      @Named(ModifiedFilesCacheImpl.MODIFIED_FILES)
+          LoadingCache<ModifiedFilesCacheKey, ImmutableList<ModifiedFile>> cache) {
+    this.cache = cache;
+  }
+
+  @Override
+  public ImmutableList<ModifiedFile> get(ModifiedFilesCacheKey key)
+      throws DiffNotAvailableException {
+    try {
+      return cache.get(key);
+    } catch (Exception e) {
+      throw new DiffNotAvailableException(e);
+    }
+  }
+
+  static class ModifiedFilesLoader
+      extends CacheLoader<ModifiedFilesCacheKey, ImmutableList<ModifiedFile>> {
+    private final GitModifiedFilesCache gitCache;
+    private final GitRepositoryManager repoManager;
+
+    @Inject
+    ModifiedFilesLoader(GitModifiedFilesCache gitCache, GitRepositoryManager repoManager) {
+      this.gitCache = gitCache;
+      this.repoManager = repoManager;
+    }
+
+    @Override
+    public ImmutableList<ModifiedFile> load(ModifiedFilesCacheKey key)
+        throws IOException, DiffNotAvailableException {
+      try (Repository repo = repoManager.openRepository(key.project());
+          RevWalk rw = new RevWalk(repo.newObjectReader())) {
+        return loadModifiedFiles(key, rw);
+      }
+    }
+
+    private ImmutableList<ModifiedFile> loadModifiedFiles(ModifiedFilesCacheKey key, RevWalk rw)
+        throws IOException, DiffNotAvailableException {
+      ObjectId aTree =
+          key.aCommit().equals(EMPTY_TREE_ID)
+              ? key.aCommit()
+              : DiffUtil.getTreeId(rw, key.aCommit());
+      ObjectId bTree = DiffUtil.getTreeId(rw, key.bCommit());
+      GitModifiedFilesCacheKey gitKey =
+          GitModifiedFilesCacheKey.builder()
+              .project(key.project())
+              .aTree(aTree)
+              .bTree(bTree)
+              .renameScore(key.renameScore())
+              .build();
+      List<ModifiedFile> modifiedFiles = gitCache.get(gitKey);
+      if (key.aCommit().equals(EMPTY_TREE_ID)) {
+        return ImmutableList.copyOf(modifiedFiles);
+      }
+      RevCommit revCommitA = DiffUtil.getRevCommit(rw, key.aCommit());
+      RevCommit revCommitB = DiffUtil.getRevCommit(rw, key.bCommit());
+      if (DiffUtil.areRelated(revCommitA, revCommitB)) {
+        return ImmutableList.copyOf(modifiedFiles);
+      }
+      Set<String> touchedFiles =
+          getTouchedFilesWithParents(
+              key, revCommitA.getParent(0).getId(), revCommitB.getParent(0).getId(), rw);
+      return modifiedFiles.stream()
+          .filter(f -> isTouched(touchedFiles, f))
+          .collect(toImmutableList());
+    }
+
+    /**
+     * Returns the paths of files that were modified between the old and new commits versus their
+     * parents (i.e. old commit vs. its parent, and new commit vs. its parent).
+     *
+     * @param key the {@link ModifiedFilesCacheKey} representing the commits we are diffing
+     * @param rw a {@link RevWalk} for the repository
+     * @return The list of modified files between the old/new commits and their parents
+     */
+    private Set<String> getTouchedFilesWithParents(
+        ModifiedFilesCacheKey key, ObjectId parentOfA, ObjectId parentOfB, RevWalk rw)
+        throws IOException {
+      try {
+        // TODO(ghareeb): as an enhancement: the 3 calls of the underlying git cache can be combined
+        GitModifiedFilesCacheKey oldVsBaseKey =
+            GitModifiedFilesCacheKey.create(
+                key.project(), parentOfA, key.aCommit(), key.renameScore(), rw);
+        List<ModifiedFile> oldVsBase = gitCache.get(oldVsBaseKey);
+
+        GitModifiedFilesCacheKey newVsBaseKey =
+            GitModifiedFilesCacheKey.create(
+                key.project(), parentOfB, key.bCommit(), key.renameScore(), rw);
+        List<ModifiedFile> newVsBase = gitCache.get(newVsBaseKey);
+
+        return Sets.union(getOldAndNewPaths(oldVsBase), getOldAndNewPaths(newVsBase));
+      } catch (DiffNotAvailableException e) {
+        logger.atWarning().log(
+            "Failed to retrieve the touched files' commits (%s, %s) and parents (%s, %s): %s",
+            key.aCommit(), key.bCommit(), parentOfA, parentOfB, e.getMessage());
+        return ImmutableSet.of();
+      }
+    }
+
+    private ImmutableSet<String> getOldAndNewPaths(List<ModifiedFile> files) {
+      return files.stream()
+          .flatMap(
+              file -> Stream.concat(Streams.stream(file.oldPath()), Streams.stream(file.newPath())))
+          .collect(ImmutableSet.toImmutableSet());
+    }
+
+    private static boolean isTouched(Set<String> touchedFilePaths, ModifiedFile modifiedFile) {
+      String oldFilePath = modifiedFile.oldPath().orElse(null);
+      String newFilePath = modifiedFile.newPath().orElse(null);
+      // One of the above file paths could be /dev/null but we need not explicitly check for this
+      // value as the set of file paths shouldn't contain it.
+      return touchedFilePaths.contains(oldFilePath) || touchedFilePaths.contains(newFilePath);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheKey.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheKey.java
new file mode 100644
index 0000000..2ac3f5e
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheKey.java
@@ -0,0 +1,108 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch.diff;
+
+import static com.google.gerrit.server.patch.DiffUtil.stringSize;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache.ModifiedFilesKeyProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Cache key for the {@link com.google.gerrit.server.patch.diff.ModifiedFilesCache} */
+@AutoValue
+public abstract class ModifiedFilesCacheKey {
+
+  /** A specific git project / repository. */
+  public abstract Project.NameKey project();
+
+  /** @return the old commit ID used in the git tree diff */
+  public abstract ObjectId aCommit();
+
+  /** @return the new commit ID used in the git tree diff */
+  public abstract ObjectId bCommit();
+
+  /**
+   * Percentage score used to identify a file as a "rename". A special value of -1 means that the
+   * computation will ignore renames and rename detection will be disabled.
+   */
+  public abstract int renameScore();
+
+  public boolean renameDetectionEnabled() {
+    return renameScore() != -1;
+  }
+
+  /** Returns the size of the object in bytes */
+  public int weight() {
+    return stringSize(project().get()) // project
+        + 20 * 2 // aCommit and bCommit
+        + 4; // renameScore
+  }
+
+  public static ModifiedFilesCacheKey.Builder builder() {
+    return new AutoValue_ModifiedFilesCacheKey.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract ModifiedFilesCacheKey.Builder project(NameKey value);
+
+    public abstract ModifiedFilesCacheKey.Builder aCommit(ObjectId value);
+
+    public abstract ModifiedFilesCacheKey.Builder bCommit(ObjectId value);
+
+    public ModifiedFilesCacheKey.Builder disableRenameDetection() {
+      renameScore(-1);
+      return this;
+    }
+
+    public abstract ModifiedFilesCacheKey.Builder renameScore(int value);
+
+    public abstract ModifiedFilesCacheKey build();
+  }
+
+  public enum Serializer implements CacheSerializer<ModifiedFilesCacheKey> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(ModifiedFilesCacheKey key) {
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      return Protos.toByteArray(
+          ModifiedFilesKeyProto.newBuilder()
+              .setProject(key.project().get())
+              .setACommit(idConverter.toByteString(key.aCommit()))
+              .setBCommit(idConverter.toByteString(key.bCommit()))
+              .setRenameScore(key.renameScore())
+              .build());
+    }
+
+    @Override
+    public ModifiedFilesCacheKey deserialize(byte[] in) {
+      ModifiedFilesKeyProto proto = Protos.parseUnchecked(ModifiedFilesKeyProto.parser(), in);
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      return ModifiedFilesCacheKey.builder()
+          .project(NameKey.parse(proto.getProject()))
+          .aCommit(idConverter.fromByteString(proto.getACommit()))
+          .bCommit(idConverter.fromByteString(proto.getBCommit()))
+          .renameScore(proto.getRenameScore())
+          .build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesWeigher.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesWeigher.java
new file mode 100644
index 0000000..512da6f
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesWeigher.java
@@ -0,0 +1,31 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch.diff;
+
+import com.google.common.cache.Weigher;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
+
+public class ModifiedFilesWeigher
+    implements Weigher<ModifiedFilesCacheKey, ImmutableList<ModifiedFile>> {
+  @Override
+  public int weigh(ModifiedFilesCacheKey key, ImmutableList<ModifiedFile> modifiedFiles) {
+    int weight = key.weight();
+    for (ModifiedFile modifiedFile : modifiedFiles) {
+      weight += modifiedFile.weight();
+    }
+    return weight;
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/filediff/AllDiffsEvaluator.java b/java/com/google/gerrit/server/patch/filediff/AllDiffsEvaluator.java
new file mode 100644
index 0000000..12decc3
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/AllDiffsEvaluator.java
@@ -0,0 +1,218 @@
+// 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.filediff;
+
+import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffUtil;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiff;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCache;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheKey;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * A helper class that computes the four {@link GitFileDiff}s for a list of {@link
+ * FileDiffCacheKey}s:
+ *
+ * <ul>
+ *   <li>old commit vs. new commit
+ *   <li>old parent vs. old commit
+ *   <li>new parent vs. new commit
+ *   <li>old parent vs. new parent
+ * </ul>
+ *
+ * The four {@link GitFileDiff} are stored in the entity class {@link AllFileGitDiffs}. We use these
+ * diffs to identify the edits due to rebase using the {@link EditTransformer} class.
+ */
+class AllDiffsEvaluator {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final RevWalk rw;
+  private final GitFileDiffCache gitCache;
+
+  interface Factory {
+    AllDiffsEvaluator create(RevWalk rw);
+  }
+
+  @Inject
+  private AllDiffsEvaluator(GitFileDiffCache gitCache, @Assisted RevWalk rw) {
+    this.gitCache = gitCache;
+    this.rw = rw;
+  }
+
+  Map<AugmentedFileDiffCacheKey, AllFileGitDiffs> execute(
+      List<AugmentedFileDiffCacheKey> augmentedKeys) throws DiffNotAvailableException {
+    ImmutableMap.Builder<AugmentedFileDiffCacheKey, AllFileGitDiffs> keyToAllDiffs =
+        ImmutableMap.builderWithExpectedSize(augmentedKeys.size());
+
+    List<AugmentedFileDiffCacheKey> keysWithRebaseEdits =
+        augmentedKeys.stream().filter(k -> !k.ignoreRebase()).collect(Collectors.toList());
+
+    // TODO(ghareeb): as an enhancement, you can batch these calls as follows.
+    // First batch: "old commit vs. new commit" and "new parent vs. new commit"
+    // Second batch: "old parent vs. old commit" and "old parent vs. new parent"
+
+    Map<FileDiffCacheKey, GitDiffEntity> mainDiffs =
+        computeGitFileDiffs(
+            createGitKeys(
+                augmentedKeys,
+                k -> k.key().oldCommit(),
+                k -> k.key().newCommit(),
+                k -> k.key().newFilePath()));
+
+    Map<FileDiffCacheKey, GitDiffEntity> oldVsParentDiffs =
+        computeGitFileDiffs(
+            createGitKeys(
+                keysWithRebaseEdits,
+                k -> k.oldParentId().get(), // oldParent is set for keysWithRebaseEdits
+                k -> k.key().oldCommit(),
+                k -> mainDiffs.get(k.key()).gitDiff().oldPath().orElse(null)));
+
+    Map<FileDiffCacheKey, GitDiffEntity> newVsParentDiffs =
+        computeGitFileDiffs(
+            createGitKeys(
+                keysWithRebaseEdits,
+                k -> k.newParentId().get(), // newParent is set for keysWithRebaseEdits
+                k -> k.key().newCommit(),
+                k -> k.key().newFilePath()));
+
+    Map<FileDiffCacheKey, GitDiffEntity> parentsDiffs =
+        computeGitFileDiffs(
+            createGitKeys(
+                keysWithRebaseEdits,
+                k -> k.oldParentId().get(),
+                k -> k.newParentId().get(),
+                k -> {
+                  GitFileDiff newVsParDiff = newVsParentDiffs.get(k.key()).gitDiff();
+                  // TODO(ghareeb): Follow up on replacing key.newFilePath as a fallback.
+                  // If the file was added between newParent and newCommit, we actually wouldn't
+                  // need to have to determine the oldParent vs. newParent diff as nothing in
+                  // that file could be an edit due to rebase anymore. Only if the returned diff
+                  // is empty, the oldParent vs. newParent diff becomes relevant again (e.g. to
+                  // identify a file deletion which was due to rebase. Check if the structure
+                  // can be improved to make this clearer. Can we maybe even skip the diff in
+                  // the first situation described?
+                  return newVsParDiff.oldPath().orElse(k.key().newFilePath());
+                }));
+
+    for (AugmentedFileDiffCacheKey augmentedKey : augmentedKeys) {
+      FileDiffCacheKey key = augmentedKey.key();
+      AllFileGitDiffs.Builder builder =
+          AllFileGitDiffs.builder().augmentedKey(augmentedKey).mainDiff(mainDiffs.get(key));
+
+      if (augmentedKey.ignoreRebase()) {
+        keyToAllDiffs.put(augmentedKey, builder.build());
+        continue;
+      }
+
+      if (oldVsParentDiffs.containsKey(key) && !oldVsParentDiffs.get(key).gitDiff().isEmpty()) {
+        builder.oldVsParentDiff(Optional.of(oldVsParentDiffs.get(key)));
+      }
+
+      if (newVsParentDiffs.containsKey(key) && !newVsParentDiffs.get(key).gitDiff().isEmpty()) {
+        builder.newVsParentDiff(Optional.of(newVsParentDiffs.get(key)));
+      }
+
+      if (parentsDiffs.containsKey(key) && !parentsDiffs.get(key).gitDiff().isEmpty()) {
+        builder.parentVsParentDiff(Optional.of(parentsDiffs.get(key)));
+      }
+
+      keyToAllDiffs.put(augmentedKey, builder.build());
+    }
+    return keyToAllDiffs.build();
+  }
+
+  /**
+   * Computes the git diff for the git keys of the input map {@code keys} parameter. The computation
+   * uses the underlying {@link GitFileDiffCache}.
+   */
+  private Map<FileDiffCacheKey, GitDiffEntity> computeGitFileDiffs(
+      Map<FileDiffCacheKey, GitFileDiffCacheKey> keys) throws DiffNotAvailableException {
+    ImmutableMap.Builder<FileDiffCacheKey, GitDiffEntity> result =
+        ImmutableMap.builderWithExpectedSize(keys.size());
+    ImmutableMap<GitFileDiffCacheKey, GitFileDiff> gitDiffs = gitCache.getAll(keys.values());
+    for (FileDiffCacheKey key : keys.keySet()) {
+      GitFileDiffCacheKey gitKey = keys.get(key);
+      GitFileDiff gitFileDiff = gitDiffs.get(gitKey);
+      result.put(key, GitDiffEntity.create(gitKey, gitFileDiff));
+    }
+    return result.build();
+  }
+
+  /**
+   * Convert a list of {@link AugmentedFileDiffCacheKey} to their corresponding {@link
+   * GitFileDiffCacheKey} which can be used to call the underlying {@link GitFileDiffCache}.
+   *
+   * @param keys a list of input {@link AugmentedFileDiffCacheKey}s.
+   * @param aCommitFn a function to compute the aCommit that will be used in the git diff.
+   * @param bCommitFn a function to compute the bCommit that will be used in the git diff.
+   * @param newPathFn a function to compute the new path of the git key.
+   * @return a map of the input {@link FileDiffCacheKey} to the {@link GitFileDiffCacheKey}.
+   */
+  private Map<FileDiffCacheKey, GitFileDiffCacheKey> createGitKeys(
+      List<AugmentedFileDiffCacheKey> keys,
+      Function<AugmentedFileDiffCacheKey, ObjectId> aCommitFn,
+      Function<AugmentedFileDiffCacheKey, ObjectId> bCommitFn,
+      Function<AugmentedFileDiffCacheKey, String> newPathFn) {
+    Map<FileDiffCacheKey, GitFileDiffCacheKey> result = new HashMap<>();
+    for (AugmentedFileDiffCacheKey key : keys) {
+      try {
+        String path = newPathFn.apply(key);
+        if (path != null) {
+          result.put(
+              key.key(),
+              createGitKey(key.key(), aCommitFn.apply(key), bCommitFn.apply(key), path, rw));
+        }
+      } catch (IOException e) {
+        // TODO(ghareeb): This implies that the output keys may not have the same size as the input.
+        // Check the caller's code path about the correctness of the computation in this case. If
+        // errors are rare, it may be better to throw an exception and fail the whole computation.
+        logger.atWarning().log("Failed to compute the git key for key %s: %s", key, e.getMessage());
+      }
+    }
+    return result;
+  }
+
+  /** Returns the {@link GitFileDiffCacheKey} for the {@code key} input parameter. */
+  private GitFileDiffCacheKey createGitKey(
+      FileDiffCacheKey key, ObjectId aCommit, ObjectId bCommit, String pathNew, RevWalk rw)
+      throws IOException {
+    ObjectId oldTreeId =
+        aCommit.equals(EMPTY_TREE_ID) ? EMPTY_TREE_ID : DiffUtil.getTreeId(rw, aCommit);
+    ObjectId newTreeId = DiffUtil.getTreeId(rw, bCommit);
+    return GitFileDiffCacheKey.builder()
+        .project(key.project())
+        .oldTree(oldTreeId)
+        .newTree(newTreeId)
+        .newFilePath(pathNew == null ? key.newFilePath() : pathNew)
+        .renameScore(key.renameScore())
+        .diffAlgorithm(key.diffAlgorithm())
+        .whitespace(key.whitespace())
+        .build();
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/filediff/AllFileGitDiffs.java b/java/com/google/gerrit/server/patch/filediff/AllFileGitDiffs.java
new file mode 100644
index 0000000..3b1886f
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/AllFileGitDiffs.java
@@ -0,0 +1,61 @@
+// 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.filediff;
+
+import com.google.auto.value.AutoValue;
+import java.util.Optional;
+
+/**
+ * An entity containing the four git diffs for a {@link FileDiffCacheKey}:
+ *
+ * <ol>
+ *   <li>The old vs. new commit
+ *   <li>The old commit vs. the old parent
+ *   <li>The new commit vs. the new parent
+ *   <li>The old parent vs. the new parent
+ * </ol>
+ */
+@AutoValue
+abstract class AllFileGitDiffs {
+  abstract AugmentedFileDiffCacheKey augmentedKey();
+
+  abstract GitDiffEntity mainDiff();
+
+  abstract Optional<GitDiffEntity> oldVsParentDiff();
+
+  abstract Optional<GitDiffEntity> newVsParentDiff();
+
+  abstract Optional<GitDiffEntity> parentVsParentDiff();
+
+  static AllFileGitDiffs.Builder builder() {
+    return new AutoValue_AllFileGitDiffs.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder augmentedKey(AugmentedFileDiffCacheKey value);
+
+    public abstract Builder mainDiff(GitDiffEntity value);
+
+    public abstract Builder oldVsParentDiff(Optional<GitDiffEntity> value);
+
+    public abstract Builder newVsParentDiff(Optional<GitDiffEntity> value);
+
+    public abstract Builder parentVsParentDiff(Optional<GitDiffEntity> value);
+
+    public abstract AllFileGitDiffs build();
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/filediff/AugmentedFileDiffCacheKey.java b/java/com/google/gerrit/server/patch/filediff/AugmentedFileDiffCacheKey.java
new file mode 100644
index 0000000..8e40452
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/AugmentedFileDiffCacheKey.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch.filediff;
+
+import com.google.auto.value.AutoValue;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * A wrapper entity to the {@link FileDiffCacheKey} that also includes the old parent commit ID, the
+ * new parent commit ID and if we should ignore computing the rebase edits for that key.
+ */
+@AutoValue
+abstract class AugmentedFileDiffCacheKey {
+  abstract FileDiffCacheKey key();
+
+  abstract boolean ignoreRebase();
+
+  abstract Optional<ObjectId> oldParentId();
+
+  abstract Optional<ObjectId> newParentId();
+
+  static Builder builder() {
+    return new AutoValue_AugmentedFileDiffCacheKey.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder oldParentId(Optional<ObjectId> value);
+
+    public abstract Builder newParentId(Optional<ObjectId> value);
+
+    public abstract Builder ignoreRebase(boolean value);
+
+    public abstract Builder key(FileDiffCacheKey value);
+
+    public abstract AugmentedFileDiffCacheKey build();
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/filediff/Edit.java b/java/com/google/gerrit/server/patch/filediff/Edit.java
new file mode 100644
index 0000000..4a698a4
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/Edit.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.server.patch.filediff;
+
+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 {
+  public 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());
+  }
+
+  public static org.eclipse.jgit.diff.Edit toJGitEdit(Edit e) {
+    return new org.eclipse.jgit.diff.Edit(e.beginA(), e.endA(), e.beginB(), e.endB());
+  }
+
+  public org.eclipse.jgit.diff.Edit asJGitEdit() {
+    return new org.eclipse.jgit.diff.Edit(beginA(), endA(), beginB(), endB());
+  }
+
+  /** 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/EditTransformer.java b/java/com/google/gerrit/server/patch/filediff/EditTransformer.java
similarity index 62%
rename from java/com/google/gerrit/server/patch/EditTransformer.java
rename to java/com/google/gerrit/server/patch/filediff/EditTransformer.java
index 6288270..55568e4 100644
--- a/java/com/google/gerrit/server/patch/EditTransformer.java
+++ b/java/com/google/gerrit/server/patch/filediff/EditTransformer.java
@@ -12,20 +12,21 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.patch;
+package com.google.gerrit.server.patch.filediff;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 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;
 import com.google.common.collect.Multimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Patch;
+import com.google.gerrit.server.patch.DiffMappings;
+import com.google.gerrit.server.patch.GitPositionTransformer;
 import com.google.gerrit.server.patch.GitPositionTransformer.Mapping;
 import com.google.gerrit.server.patch.GitPositionTransformer.OmitPositionOnConflict;
 import com.google.gerrit.server.patch.GitPositionTransformer.Position;
@@ -36,7 +37,6 @@
 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/filediff/FileDiffCache.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCache.java
new file mode 100644
index 0000000..a9bcf03
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCache.java
@@ -0,0 +1,45 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch.filediff;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+
+/**
+ * This cache computes the git diff for a single file path and adds some extra logic, e.g. for
+ * identifying edits that are due to rebase.
+ */
+public interface FileDiffCache {
+  /**
+   * Returns the file diff for a single file path identified by its key.
+   *
+   * @param key identifies two git commits, a specific file path and other diff parameters.
+   * @return the file diff for a single file path identified by its key.
+   * @throws DiffNotAvailableException if the commit IDs of the key are invalid for this project or
+   *     if file contents could not be read.
+   */
+  FileDiffOutput get(FileDiffCacheKey key) throws DiffNotAvailableException;
+
+  /**
+   * Returns the file diff for a collection of file paths identified by their keys.
+   *
+   * @param keys identifying different file paths of different projects.
+   * @return a map of the input keys to their corresponding git file diffs.
+   * @throws DiffNotAvailableException if the diff failed to be evaluated for one or more of the
+   *     input keys due to invalid commit IDs or if file contents could not be read.
+   */
+  ImmutableMap<FileDiffCacheKey, FileDiffOutput> getAll(Iterable<FileDiffCacheKey> keys)
+      throws DiffNotAvailableException;
+}
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
new file mode 100644
index 0000000..1bb407d
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
@@ -0,0 +1,545 @@
+//  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.filediff;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.Streams;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.AutoMerger;
+import com.google.gerrit.server.patch.ComparisonType;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffUtil;
+import com.google.gerrit.server.patch.Text;
+import com.google.gerrit.server.patch.filediff.EditTransformer.ContextAwareEdit;
+import com.google.gerrit.server.patch.gitfilediff.FileHeaderUtil;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiff;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl.DiffAlgorithmFactory;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.diff.EditList;
+import org.eclipse.jgit.diff.RawText;
+import org.eclipse.jgit.diff.RawTextComparator;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.patch.FileHeader;
+import org.eclipse.jgit.patch.FileHeader.PatchType;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Cache for the single file diff between two commits for a single file path. This cache adds extra
+ * Gerrit logic such as identifying edits due to rebase.
+ *
+ * <p>If the {@link FileDiffCacheKey#oldCommit()} is equal to {@link
+ * org.eclipse.jgit.lib.Constants#EMPTY_TREE_ID}, the git diff will be evaluated against the empty
+ * tree.
+ */
+public class FileDiffCacheImpl implements FileDiffCache {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private static final String DIFF = "gerrit_file_diff";
+
+  private final LoadingCache<FileDiffCacheKey, FileDiffOutput> cache;
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        bind(FileDiffCache.class).to(FileDiffCacheImpl.class);
+
+        factory(AllDiffsEvaluator.Factory.class);
+
+        persist(DIFF, FileDiffCacheKey.class, FileDiffOutput.class)
+            .maximumWeight(10 << 20)
+            .weigher(FileDiffWeigher.class)
+            .version(2)
+            .keySerializer(FileDiffCacheKey.Serializer.INSTANCE)
+            .valueSerializer(FileDiffOutput.Serializer.INSTANCE)
+            .loader(FileDiffLoader.class);
+      }
+    };
+  }
+
+  private enum MagicPath {
+    COMMIT,
+    MERGE_LIST
+  }
+
+  @Inject
+  public FileDiffCacheImpl(@Named(DIFF) LoadingCache<FileDiffCacheKey, FileDiffOutput> cache) {
+    this.cache = cache;
+  }
+
+  @Override
+  public FileDiffOutput get(FileDiffCacheKey key) throws DiffNotAvailableException {
+    try {
+      return cache.get(key);
+    } catch (ExecutionException e) {
+      throw new DiffNotAvailableException(e);
+    }
+  }
+
+  @Override
+  public ImmutableMap<FileDiffCacheKey, FileDiffOutput> getAll(Iterable<FileDiffCacheKey> keys)
+      throws DiffNotAvailableException {
+    try {
+      ImmutableMap<FileDiffCacheKey, FileDiffOutput> result = cache.getAll(keys);
+      if (result.size() != Iterables.size(keys)) {
+        throw new DiffNotAvailableException(
+            String.format(
+                "Failed to load the value for all %d keys. Returned "
+                    + "map contains only %d values",
+                Iterables.size(keys), result.size()));
+      }
+      return result;
+    } catch (ExecutionException e) {
+      throw new DiffNotAvailableException(e);
+    }
+  }
+
+  static class FileDiffLoader extends CacheLoader<FileDiffCacheKey, FileDiffOutput> {
+    private final GitRepositoryManager repoManager;
+    private final AllDiffsEvaluator.Factory allDiffsEvaluatorFactory;
+
+    @Inject
+    FileDiffLoader(
+        AllDiffsEvaluator.Factory allDiffsEvaluatorFactory, GitRepositoryManager manager) {
+      this.allDiffsEvaluatorFactory = allDiffsEvaluatorFactory;
+      this.repoManager = manager;
+    }
+
+    @Override
+    public FileDiffOutput load(FileDiffCacheKey key) throws IOException, DiffNotAvailableException {
+      return loadAll(ImmutableList.of(key)).get(key);
+    }
+
+    @Override
+    public Map<FileDiffCacheKey, FileDiffOutput> loadAll(Iterable<? extends FileDiffCacheKey> keys)
+        throws DiffNotAvailableException {
+      ImmutableMap.Builder<FileDiffCacheKey, FileDiffOutput> result = ImmutableMap.builder();
+
+      Map<Project.NameKey, List<FileDiffCacheKey>> keysByProject =
+          Streams.stream(keys).distinct().collect(Collectors.groupingBy(FileDiffCacheKey::project));
+
+      for (Project.NameKey project : keysByProject.keySet()) {
+        List<FileDiffCacheKey> fileKeys = new ArrayList<>();
+
+        try (Repository repo = repoManager.openRepository(project);
+            ObjectReader reader = repo.newObjectReader();
+            RevWalk rw = new RevWalk(reader)) {
+
+          for (FileDiffCacheKey key : keysByProject.get(project)) {
+            if (key.newFilePath().equals(Patch.COMMIT_MSG)) {
+              result.put(key, createMagicPathEntry(key, reader, rw, MagicPath.COMMIT));
+            } else if (key.newFilePath().equals(Patch.MERGE_LIST)) {
+              result.put(key, createMagicPathEntry(key, reader, rw, MagicPath.MERGE_LIST));
+            } else {
+              fileKeys.add(key);
+            }
+          }
+          result.putAll(createFileEntries(reader, fileKeys, rw));
+        } catch (IOException e) {
+          logger.atWarning().log("Failed to open the repository %s: %s", project, e.getMessage());
+        }
+      }
+      return result.build();
+    }
+
+    private ComparisonType getComparisonType(
+        RevWalk rw, ObjectReader reader, ObjectId oldCommitId, ObjectId newCommitId)
+        throws IOException {
+      RevCommit oldCommit = DiffUtil.getRevCommit(rw, oldCommitId);
+      RevCommit newCommit = DiffUtil.getRevCommit(rw, newCommitId);
+      for (int i = 0; i < newCommit.getParentCount(); i++) {
+        if (newCommit.getParent(i).equals(oldCommit)) {
+          return ComparisonType.againstParent(i + 1);
+        }
+      }
+      // TODO(ghareeb): it's not trivial to distinguish if diff with old commit is against another
+      // patchset or auto-merge. Looking at the commit message of old commit gives a strong
+      // signal that we are diffing against auto-merge, though not 100% accurate (e.g. if old commit
+      // has the auto-merge prefix in the commit message). A better resolution would be to move the
+      // COMMIT_MSG and MERGE_LIST evaluations outside of the diff cache. For more details, see
+      // discussion in
+      // https://gerrit-review.googlesource.com/c/gerrit/+/280519/6..18/java/com/google/gerrit/server/patch/FileDiffCache.java#b540
+      Text oldCommitMsgTxt = Text.forCommit(reader, oldCommit);
+      if (oldCommitMsgTxt.size() > 0
+          && oldCommitMsgTxt.getString(0).startsWith(AutoMerger.AUTO_MERGE_MSG_PREFIX)) {
+        return ComparisonType.againstAutoMerge();
+      }
+      return ComparisonType.againstOtherPatchSet();
+    }
+
+    /**
+     * Creates a {@link FileDiffOutput} entry for the "Commit message" and "Merge list" file paths.
+     */
+    private FileDiffOutput createMagicPathEntry(
+        FileDiffCacheKey key, ObjectReader reader, RevWalk rw, MagicPath magicPath) {
+      try {
+        RawTextComparator cmp = comparatorFor(key.whitespace());
+        ComparisonType comparisonType =
+            getComparisonType(rw, reader, key.oldCommit(), key.newCommit());
+        RevCommit aCommit = DiffUtil.getRevCommit(rw, key.oldCommit());
+        RevCommit bCommit = DiffUtil.getRevCommit(rw, key.newCommit());
+        return magicPath == MagicPath.COMMIT
+            ? createCommitEntry(reader, aCommit, bCommit, comparisonType, cmp, key.diffAlgorithm())
+            : createMergeListEntry(
+                reader, aCommit, bCommit, comparisonType, cmp, key.diffAlgorithm());
+      } catch (IOException e) {
+        logger.atWarning().log("Failed to compute commit entry for key %s", key);
+      }
+      return FileDiffOutput.empty(key.newFilePath(), key.oldCommit(), key.newCommit());
+    }
+
+    private static RawTextComparator comparatorFor(Whitespace ws) {
+      switch (ws) {
+        case IGNORE_ALL:
+          return RawTextComparator.WS_IGNORE_ALL;
+
+        case IGNORE_TRAILING:
+          return RawTextComparator.WS_IGNORE_TRAILING;
+
+        case IGNORE_LEADING_AND_TRAILING:
+          return RawTextComparator.WS_IGNORE_CHANGE;
+
+        case IGNORE_NONE:
+        default:
+          return RawTextComparator.DEFAULT;
+      }
+    }
+
+    private FileDiffOutput createCommitEntry(
+        ObjectReader reader,
+        RevCommit oldCommit,
+        RevCommit newCommit,
+        ComparisonType comparisonType,
+        RawTextComparator rawTextComparator,
+        GitFileDiffCacheImpl.DiffAlgorithm diffAlgorithm)
+        throws IOException {
+      Text aText =
+          comparisonType.isAgainstParentOrAutoMerge()
+              ? Text.EMPTY
+              : Text.forCommit(reader, oldCommit);
+      Text bText = Text.forCommit(reader, newCommit);
+      return createMagicFileDiffOutput(
+          oldCommit,
+          newCommit,
+          comparisonType,
+          rawTextComparator,
+          aText,
+          bText,
+          Patch.COMMIT_MSG,
+          diffAlgorithm);
+    }
+
+    private FileDiffOutput createMergeListEntry(
+        ObjectReader reader,
+        RevCommit oldCommit,
+        RevCommit newCommit,
+        ComparisonType comparisonType,
+        RawTextComparator rawTextComparator,
+        GitFileDiffCacheImpl.DiffAlgorithm diffAlgorithm)
+        throws IOException {
+      Text aText =
+          comparisonType.isAgainstParentOrAutoMerge()
+              ? Text.EMPTY
+              : Text.forMergeList(comparisonType, reader, oldCommit);
+      Text bText = Text.forMergeList(comparisonType, reader, newCommit);
+      return createMagicFileDiffOutput(
+          oldCommit,
+          newCommit,
+          comparisonType,
+          rawTextComparator,
+          aText,
+          bText,
+          Patch.MERGE_LIST,
+          diffAlgorithm);
+    }
+
+    private static FileDiffOutput createMagicFileDiffOutput(
+        ObjectId oldCommit,
+        ObjectId newCommit,
+        ComparisonType comparisonType,
+        RawTextComparator rawTextComparator,
+        Text aText,
+        Text bText,
+        String fileName,
+        GitFileDiffCacheImpl.DiffAlgorithm diffAlgorithm) {
+      byte[] rawHdr = getRawHeader(!comparisonType.isAgainstParentOrAutoMerge(), fileName);
+      byte[] aContent = aText.getContent();
+      byte[] bContent = bText.getContent();
+      long size = bContent.length;
+      long sizeDelta = size - aContent.length;
+      RawText aRawText = new RawText(aContent);
+      RawText bRawText = new RawText(bContent);
+      EditList edits =
+          DiffAlgorithmFactory.create(diffAlgorithm).diff(rawTextComparator, aRawText, bRawText);
+      FileHeader fileHeader = new FileHeader(rawHdr, edits, PatchType.UNIFIED);
+      Patch.ChangeType changeType = FileHeaderUtil.getChangeType(fileHeader);
+      return FileDiffOutput.builder()
+          .oldCommitId(oldCommit)
+          .newCommitId(newCommit)
+          .comparisonType(comparisonType)
+          .oldPath(FileHeaderUtil.getOldPath(fileHeader))
+          .newPath(FileHeaderUtil.getNewPath(fileHeader))
+          .changeType(changeType)
+          .patchType(Optional.of(FileHeaderUtil.getPatchType(fileHeader)))
+          .headerLines(FileHeaderUtil.getHeaderLines(fileHeader))
+          .edits(
+              asTaggedEdits(
+                  edits.stream().map(Edit::fromJGitEdit).collect(Collectors.toList()),
+                  ImmutableList.of()))
+          .size(size)
+          .sizeDelta(sizeDelta)
+          .build();
+    }
+
+    private static byte[] getRawHeader(boolean hasA, String fileName) {
+      StringBuilder hdr = new StringBuilder();
+      hdr.append("diff --git");
+      if (hasA) {
+        hdr.append(" a/").append(fileName);
+      } else {
+        hdr.append(" ").append(FileHeader.DEV_NULL);
+      }
+      hdr.append(" b/").append(fileName);
+      hdr.append("\n");
+
+      if (hasA) {
+        hdr.append("--- a/").append(fileName).append("\n");
+      } else {
+        hdr.append("--- ").append(FileHeader.DEV_NULL).append("\n");
+      }
+      hdr.append("+++ b/").append(fileName).append("\n");
+      return hdr.toString().getBytes(UTF_8);
+    }
+
+    private Map<FileDiffCacheKey, FileDiffOutput> createFileEntries(
+        ObjectReader reader, List<FileDiffCacheKey> keys, RevWalk rw)
+        throws DiffNotAvailableException, IOException {
+      Map<AugmentedFileDiffCacheKey, AllFileGitDiffs> allFileDiffs =
+          allDiffsEvaluatorFactory.create(rw).execute(wrapKeys(keys, rw));
+
+      Map<FileDiffCacheKey, FileDiffOutput> result = new HashMap<>();
+
+      for (AugmentedFileDiffCacheKey augmentedKey : allFileDiffs.keySet()) {
+        AllFileGitDiffs allDiffs = allFileDiffs.get(augmentedKey);
+
+        FileEdits rebaseFileEdits = FileEdits.empty();
+        if (!augmentedKey.ignoreRebase()) {
+          rebaseFileEdits = computeRebaseEdits(allDiffs);
+        }
+        List<Edit> rebaseEdits = rebaseFileEdits.edits();
+
+        RevTree aTree = rw.parseTree(allDiffs.mainDiff().gitKey().oldTree());
+        RevTree bTree = rw.parseTree(allDiffs.mainDiff().gitKey().newTree());
+        GitFileDiff mainGitDiff = allDiffs.mainDiff().gitDiff();
+
+        Long oldSize =
+            mainGitDiff.oldMode().isPresent() && mainGitDiff.oldPath().isPresent()
+                ? new FileSizeEvaluator(reader, aTree)
+                    .compute(
+                        mainGitDiff.oldId(),
+                        mainGitDiff.oldMode().get(),
+                        mainGitDiff.oldPath().get())
+                : 0;
+        Long newSize =
+            mainGitDiff.newMode().isPresent() && mainGitDiff.newPath().isPresent()
+                ? new FileSizeEvaluator(reader, bTree)
+                    .compute(
+                        mainGitDiff.newId(),
+                        mainGitDiff.newMode().get(),
+                        mainGitDiff.newPath().get())
+                : 0;
+
+        ObjectId oldCommit = augmentedKey.key().oldCommit();
+        ObjectId newCommit = augmentedKey.key().newCommit();
+        FileDiffOutput fileDiff =
+            FileDiffOutput.builder()
+                .oldCommitId(oldCommit)
+                .newCommitId(newCommit)
+                .comparisonType(getComparisonType(rw, reader, oldCommit, newCommit))
+                .changeType(mainGitDiff.changeType())
+                .patchType(mainGitDiff.patchType())
+                .oldPath(mainGitDiff.oldPath())
+                .newPath(mainGitDiff.newPath())
+                .headerLines(FileHeaderUtil.getHeaderLines(mainGitDiff.fileHeader()))
+                .edits(asTaggedEdits(mainGitDiff.edits(), rebaseEdits))
+                .size(newSize)
+                .sizeDelta(newSize - oldSize)
+                .build();
+
+        result.put(augmentedKey.key(), fileDiff);
+      }
+
+      return result;
+    }
+
+    /**
+     * Convert the list of input keys {@link FileDiffCacheKey} to a list of {@link
+     * AugmentedFileDiffCacheKey} that also include the old and new parent commit IDs, and a boolean
+     * that indicates whether we should include the rebase edits for each key.
+     *
+     * <p>The output list is expected to have the same size of the input list, i.e. we map all keys.
+     */
+    private List<AugmentedFileDiffCacheKey> wrapKeys(List<FileDiffCacheKey> keys, RevWalk rw) {
+      List<AugmentedFileDiffCacheKey> result = new ArrayList<>();
+      for (FileDiffCacheKey key : keys) {
+        if (key.oldCommit().equals(EMPTY_TREE_ID)) {
+          result.add(AugmentedFileDiffCacheKey.builder().key(key).ignoreRebase(true).build());
+          continue;
+        }
+        try {
+          RevCommit oldRevCommit = DiffUtil.getRevCommit(rw, key.oldCommit());
+          RevCommit newRevCommit = DiffUtil.getRevCommit(rw, key.newCommit());
+          if (!DiffUtil.areRelated(oldRevCommit, newRevCommit)) {
+            result.add(
+                AugmentedFileDiffCacheKey.builder()
+                    .key(key)
+                    .oldParentId(Optional.of(oldRevCommit.getParent(0).getId()))
+                    .newParentId(Optional.of(newRevCommit.getParent(0).getId()))
+                    .ignoreRebase(false)
+                    .build());
+          } else {
+            result.add(AugmentedFileDiffCacheKey.builder().key(key).ignoreRebase(true).build());
+          }
+        } catch (IOException e) {
+          logger.atWarning().log(
+              "Failed to evaluate commits relation for key "
+                  + key
+                  + ". Skipping this key: "
+                  + e.getMessage(),
+              e);
+          result.add(AugmentedFileDiffCacheKey.builder().key(key).ignoreRebase(true).build());
+        }
+      }
+      return result;
+    }
+
+    private static ImmutableList<TaggedEdit> asTaggedEdits(
+        List<Edit> normalEdits, List<Edit> rebaseEdits) {
+      Set<Edit> rebaseEditsSet = new HashSet<>(rebaseEdits);
+      ImmutableList.Builder<TaggedEdit> result =
+          ImmutableList.builderWithExpectedSize(normalEdits.size());
+      for (Edit e : normalEdits) {
+        result.add(TaggedEdit.create(e, rebaseEditsSet.contains(e)));
+      }
+      return result.build();
+    }
+
+    /**
+     * Computes the subset of edits that are due to rebase between 2 commits.
+     *
+     * <p>The input parameter {@link AllFileGitDiffs#mainDiff} contains all the edits in
+     * consideration. Of those, we identify the edits due to rebase as a function of:
+     *
+     * <ol>
+     *   <li>The edits between the old commit and its parent {@link
+     *       AllFileGitDiffs#oldVsParentDiff}.
+     *   <li>The edits between the new commit and its parent {@link
+     *       AllFileGitDiffs#newVsParentDiff}.
+     *   <li>The edits between the parents of the old commit and new commits {@link
+     *       AllFileGitDiffs#parentVsParentDiff}.
+     * </ol>
+     *
+     * @param diffs an entity containing 4 sets of edits: those between the old and new commit,
+     *     between the old and new commits vs. their parents, and between the old and new parents.
+     * @return the list of edits that are due to rebase.
+     */
+    private FileEdits computeRebaseEdits(AllFileGitDiffs diffs) {
+      if (!diffs.parentVsParentDiff().isPresent()) {
+        return FileEdits.empty();
+      }
+
+      GitFileDiff parentVsParentDiff = diffs.parentVsParentDiff().get().gitDiff();
+
+      EditTransformer editTransformer =
+          new EditTransformer(
+              ImmutableList.of(
+                  FileEdits.create(
+                      parentVsParentDiff.edits().stream().collect(toImmutableList()),
+                      parentVsParentDiff.oldPath(),
+                      parentVsParentDiff.newPath())));
+
+      if (diffs.oldVsParentDiff().isPresent()) {
+        GitFileDiff oldVsParDiff = diffs.oldVsParentDiff().get().gitDiff();
+        editTransformer.transformReferencesOfSideA(
+            ImmutableList.of(
+                FileEdits.create(
+                    oldVsParDiff.edits().stream().collect(toImmutableList()),
+                    oldVsParDiff.oldPath(),
+                    oldVsParDiff.newPath())));
+      }
+
+      if (diffs.newVsParentDiff().isPresent()) {
+        GitFileDiff newVsParDiff = diffs.newVsParentDiff().get().gitDiff();
+        editTransformer.transformReferencesOfSideB(
+            ImmutableList.of(
+                FileEdits.create(
+                    newVsParDiff.edits().stream().collect(toImmutableList()),
+                    newVsParDiff.oldPath(),
+                    newVsParDiff.newPath())));
+      }
+
+      Multimap<String, ContextAwareEdit> editsPerFilePath = editTransformer.getEditsPerFilePath();
+
+      if (editsPerFilePath.isEmpty()) {
+        return FileEdits.empty();
+      }
+
+      // editsPerFilePath is expected to have a single item representing the file
+      String filePath = editsPerFilePath.keys().iterator().next();
+      Collection<ContextAwareEdit> edits = editsPerFilePath.get(filePath);
+      return FileEdits.create(
+          edits.stream()
+              .map(ContextAwareEdit::toEdit)
+              .filter(Optional::isPresent)
+              .map(Optional::get)
+              .map(Edit::fromJGitEdit)
+              .collect(toImmutableList()),
+          edits.iterator().next().getOldFilePath(),
+          edits.iterator().next().getNewFilePath());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheKey.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheKey.java
new file mode 100644
index 0000000..a478fcf
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheKey.java
@@ -0,0 +1,130 @@
+// 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.filediff;
+
+import static com.google.gerrit.server.patch.DiffUtil.stringSize;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache.FileDiffKeyProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl.DiffAlgorithm;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Cache key for the {@link FileDiffCache}. */
+@AutoValue
+public abstract class FileDiffCacheKey {
+
+  /** A specific git project / repository. */
+  public abstract Project.NameKey project();
+
+  /** The 20 bytes SHA-1 commit ID of the old commit used in the diff. */
+  public abstract ObjectId oldCommit();
+
+  /** The 20 bytes SHA-1 commit ID of the new commit used in the diff. */
+  public abstract ObjectId newCommit();
+
+  /** File path identified by its name. */
+  public abstract String newFilePath();
+
+  /**
+   * Percentage score used to identify a file as a "rename". A special value of -1 means that the
+   * computation will ignore renames and rename detection will be disabled.
+   */
+  public abstract int renameScore();
+
+  /** The diff algorithm that should be used in the computation. */
+  public abstract DiffAlgorithm diffAlgorithm();
+
+  public abstract DiffPreferencesInfo.Whitespace whitespace();
+
+  /** Number of bytes that this entity occupies. */
+  public int weight() {
+    return stringSize(project().get())
+        + 20 * 2 // old and new commits
+        + stringSize(newFilePath())
+        + 4 // renameScore
+        + 4 // diffAlgorithm
+        + 4; // whitespace
+  }
+
+  public static FileDiffCacheKey.Builder builder() {
+    return new AutoValue_FileDiffCacheKey.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract FileDiffCacheKey.Builder project(NameKey value);
+
+    public abstract FileDiffCacheKey.Builder oldCommit(ObjectId value);
+
+    public abstract FileDiffCacheKey.Builder newCommit(ObjectId value);
+
+    public abstract FileDiffCacheKey.Builder newFilePath(String value);
+
+    public abstract FileDiffCacheKey.Builder renameScore(int value);
+
+    public FileDiffCacheKey.Builder disableRenameDetection() {
+      renameScore(-1);
+      return this;
+    }
+
+    public abstract FileDiffCacheKey.Builder diffAlgorithm(DiffAlgorithm value);
+
+    public abstract FileDiffCacheKey.Builder whitespace(Whitespace value);
+
+    public abstract FileDiffCacheKey build();
+  }
+
+  public enum Serializer implements CacheSerializer<FileDiffCacheKey> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(FileDiffCacheKey key) {
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      return Protos.toByteArray(
+          FileDiffKeyProto.newBuilder()
+              .setProject(key.project().get())
+              .setOldCommit(idConverter.toByteString(key.oldCommit()))
+              .setNewCommit(idConverter.toByteString(key.newCommit()))
+              .setFilePath(key.newFilePath())
+              .setRenameScore(key.renameScore())
+              .setDiffAlgorithm(key.diffAlgorithm().name())
+              .setWhitespace(key.whitespace().name())
+              .build());
+    }
+
+    @Override
+    public FileDiffCacheKey deserialize(byte[] in) {
+      FileDiffKeyProto proto = Protos.parseUnchecked(FileDiffKeyProto.parser(), in);
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      return FileDiffCacheKey.builder()
+          .project(Project.nameKey(proto.getProject()))
+          .oldCommit(idConverter.fromByteString(proto.getOldCommit()))
+          .newCommit(idConverter.fromByteString(proto.getNewCommit()))
+          .newFilePath(proto.getFilePath())
+          .renameScore(proto.getRenameScore())
+          .diffAlgorithm(DiffAlgorithm.valueOf(proto.getDiffAlgorithm()))
+          .whitespace(Whitespace.valueOf(proto.getWhitespace()))
+          .build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
new file mode 100644
index 0000000..e7f47ef
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
@@ -0,0 +1,278 @@
+//  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.filediff;
+
+import static com.google.gerrit.server.patch.DiffUtil.stringSize;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.Patch.PatchType;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache.FileDiffOutputProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import com.google.gerrit.server.patch.ComparisonType;
+import com.google.protobuf.Descriptors.FieldDescriptor;
+import java.io.Serializable;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** File diff for a single file path. Produced as output of the {@link FileDiffCache}. */
+@AutoValue
+public abstract class FileDiffOutput implements Serializable {
+  private static final long serialVersionUID = 1L;
+
+  /** The 20 bytes SHA-1 object ID of the old git commit used in the diff. */
+  public abstract ObjectId oldCommitId();
+
+  /** The 20 bytes SHA-1 object ID of the new git commit used in the diff. */
+  public abstract ObjectId newCommitId();
+
+  /** Comparison type of old and new commits: against another patchset, parent or auto-merge. */
+  public abstract ComparisonType comparisonType();
+
+  /**
+   * The file path at the old commit. Returns an empty Optional if {@link #changeType()} is equal to
+   * {@link ChangeType#ADDED}.
+   */
+  public abstract Optional<String> oldPath();
+
+  /**
+   * The file path at the new commit. Returns an empty optional if {@link #changeType()} is equal to
+   * {@link ChangeType#DELETED}.
+   */
+  public abstract Optional<String> newPath();
+
+  /** The change type of the underlying file, e.g. added, deleted, renamed, etc... */
+  public abstract Patch.ChangeType changeType();
+
+  /** The patch type of the underlying file, e.g. unified, binary , etc... */
+  public abstract Optional<Patch.PatchType> patchType();
+
+  /**
+   * A list of strings representation of the header lines of the {@link
+   * org.eclipse.jgit.patch.FileHeader} that is produced as output of the diff.
+   */
+  public abstract ImmutableList<String> headerLines();
+
+  /** The list of edits resulting from the diff hunks of the file. */
+  public abstract ImmutableList<TaggedEdit> edits();
+
+  /** The file size at the new commit. */
+  public abstract long size();
+
+  /** Difference in file size between the old and new commits. */
+  public abstract long sizeDelta();
+
+  /** A boolean indicating if all underlying edits of the file diff are due to rebase. */
+  public boolean allEditsDueToRebase() {
+    return !edits().isEmpty() && edits().stream().allMatch(TaggedEdit::dueToRebase);
+  }
+
+  /** Returns the number of inserted lines for the file diff. */
+  public int insertions() {
+    int ins = 0;
+    for (TaggedEdit e : edits()) {
+      if (!e.dueToRebase()) {
+        ins += e.edit().endB() - e.edit().beginB();
+      }
+    }
+    return ins;
+  }
+
+  /** Returns the number of deleted lines for the file diff. */
+  public int deletions() {
+    int del = 0;
+    for (TaggedEdit e : edits()) {
+      if (!e.dueToRebase()) {
+        del += e.edit().endA() - e.edit().beginA();
+      }
+    }
+    return del;
+  }
+
+  /** Returns an entity representing an unchanged file between two commits. */
+  public static FileDiffOutput empty(String filePath, ObjectId oldCommitId, ObjectId newCommitId) {
+    return builder()
+        .oldCommitId(oldCommitId)
+        .newCommitId(newCommitId)
+        .comparisonType(ComparisonType.againstOtherPatchSet()) // not important
+        .oldPath(Optional.empty())
+        .newPath(Optional.of(filePath))
+        .changeType(ChangeType.MODIFIED)
+        .headerLines(ImmutableList.of())
+        .edits(ImmutableList.of())
+        .size(0)
+        .sizeDelta(0)
+        .build();
+  }
+
+  /** Returns true if this entity represents an unchanged file between two commits. */
+  public boolean isEmpty() {
+    return headerLines().isEmpty() && edits().isEmpty();
+  }
+
+  public static Builder builder() {
+    return new AutoValue_FileDiffOutput.Builder();
+  }
+
+  public int weight() {
+    int result = 0;
+    if (oldPath().isPresent()) {
+      result += stringSize(oldPath().get());
+    }
+    if (newPath().isPresent()) {
+      result += stringSize(newPath().get());
+    }
+    result += 20 + 20; // old and new commit IDs
+    result += 4; // comparison type
+    result += 4; // changeType
+    if (patchType().isPresent()) {
+      result += 4;
+    }
+    result += 4 + 4; // insertions and deletions
+    result += 4 + 4; // size and size delta
+    result += 20 * edits().size(); // each edit is 4 Integers + boolean = 4 * 4 + 4 = 20
+    for (String s : headerLines()) {
+      s += stringSize(s);
+    }
+    return result;
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder oldCommitId(ObjectId value);
+
+    public abstract Builder newCommitId(ObjectId value);
+
+    public abstract Builder comparisonType(ComparisonType value);
+
+    public abstract Builder oldPath(Optional<String> value);
+
+    public abstract Builder newPath(Optional<String> value);
+
+    public abstract Builder changeType(ChangeType value);
+
+    public abstract Builder patchType(Optional<PatchType> value);
+
+    public abstract Builder headerLines(ImmutableList<String> value);
+
+    public abstract Builder edits(ImmutableList<TaggedEdit> value);
+
+    public abstract Builder size(long value);
+
+    public abstract Builder sizeDelta(long value);
+
+    public abstract FileDiffOutput build();
+  }
+
+  public enum Serializer implements CacheSerializer<FileDiffOutput> {
+    INSTANCE;
+
+    private static final FieldDescriptor OLD_PATH_DESCRIPTOR =
+        FileDiffOutputProto.getDescriptor().findFieldByNumber(1);
+
+    private static final FieldDescriptor NEW_PATH_DESCRIPTOR =
+        FileDiffOutputProto.getDescriptor().findFieldByNumber(2);
+
+    private static final FieldDescriptor PATCH_TYPE_DESCRIPTOR =
+        FileDiffOutputProto.getDescriptor().findFieldByNumber(4);
+
+    @Override
+    public byte[] serialize(FileDiffOutput fileDiff) {
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      FileDiffOutputProto.Builder builder =
+          FileDiffOutputProto.newBuilder()
+              .setOldCommit(idConverter.toByteString(fileDiff.oldCommitId().toObjectId()))
+              .setNewCommit(idConverter.toByteString(fileDiff.newCommitId().toObjectId()))
+              .setComparisonType(fileDiff.comparisonType().toProto())
+              .setSize(fileDiff.size())
+              .setSizeDelta(fileDiff.sizeDelta())
+              .addAllHeaderLines(fileDiff.headerLines())
+              .setChangeType(fileDiff.changeType().name())
+              .addAllEdits(
+                  fileDiff.edits().stream()
+                      .map(
+                          e ->
+                              FileDiffOutputProto.TaggedEdit.newBuilder()
+                                  .setEdit(
+                                      FileDiffOutputProto.Edit.newBuilder()
+                                          .setBeginA(e.edit().beginA())
+                                          .setEndA(e.edit().endA())
+                                          .setBeginB(e.edit().beginB())
+                                          .setEndB(e.edit().endB())
+                                          .build())
+                                  .setDueToRebase(e.dueToRebase())
+                                  .build())
+                      .collect(Collectors.toList()));
+
+      if (fileDiff.oldPath().isPresent()) {
+        builder.setOldPath(fileDiff.oldPath().get());
+      }
+
+      if (fileDiff.newPath().isPresent()) {
+        builder.setNewPath(fileDiff.newPath().get());
+      }
+
+      if (fileDiff.patchType().isPresent()) {
+        builder.setPatchType(fileDiff.patchType().get().name());
+      }
+
+      return Protos.toByteArray(builder.build());
+    }
+
+    @Override
+    public FileDiffOutput deserialize(byte[] in) {
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      FileDiffOutputProto proto = Protos.parseUnchecked(FileDiffOutputProto.parser(), in);
+      FileDiffOutput.Builder builder = FileDiffOutput.builder();
+      builder
+          .oldCommitId(idConverter.fromByteString(proto.getOldCommit()))
+          .newCommitId(idConverter.fromByteString(proto.getNewCommit()))
+          .comparisonType(ComparisonType.fromProto(proto.getComparisonType()))
+          .size(proto.getSize())
+          .sizeDelta(proto.getSizeDelta())
+          .headerLines(proto.getHeaderLinesList().stream().collect(ImmutableList.toImmutableList()))
+          .changeType(ChangeType.valueOf(proto.getChangeType()))
+          .edits(
+              proto.getEditsList().stream()
+                  .map(
+                      e ->
+                          TaggedEdit.create(
+                              Edit.create(
+                                  e.getEdit().getBeginA(),
+                                  e.getEdit().getEndA(),
+                                  e.getEdit().getBeginB(),
+                                  e.getEdit().getEndB()),
+                              e.getDueToRebase()))
+                  .collect(ImmutableList.toImmutableList()));
+
+      if (proto.hasField(OLD_PATH_DESCRIPTOR)) {
+        builder.oldPath(Optional.of(proto.getOldPath()));
+      }
+      if (proto.hasField(NEW_PATH_DESCRIPTOR)) {
+        builder.newPath(Optional.of(proto.getNewPath()));
+      }
+      if (proto.hasField(PATCH_TYPE_DESCRIPTOR)) {
+        builder.patchType(Optional.of(Patch.PatchType.valueOf(proto.getPatchType())));
+      }
+      return builder.build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffWeigher.java b/java/com/google/gerrit/server/patch/filediff/FileDiffWeigher.java
new file mode 100644
index 0000000..8eda234
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffWeigher.java
@@ -0,0 +1,29 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch.filediff;
+
+import com.google.common.cache.Weigher;
+
+/**
+ * A weigher for the {@link FileDiffCache} key and value. This is used by the cache backend to
+ * assign weights for cache entries and is used for evictions.
+ */
+public class FileDiffWeigher implements Weigher<FileDiffCacheKey, FileDiffOutput> {
+
+  @Override
+  public int weigh(FileDiffCacheKey key, FileDiffOutput fileDiffOutput) {
+    return key.weight() + fileDiffOutput.weight();
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/filediff/FileEdits.java b/java/com/google/gerrit/server/patch/filediff/FileEdits.java
new file mode 100644
index 0000000..a009a02
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/FileEdits.java
@@ -0,0 +1,51 @@
+//  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.filediff;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import java.util.Optional;
+
+/**
+ * An entity class containing the list of edits between two commits for a file, and the old and new
+ * paths.
+ */
+@AutoValue
+public abstract class FileEdits {
+  public static FileEdits create(
+      ImmutableList<Edit> edits, Optional<String> oldPath, Optional<String> newPath) {
+    return new AutoValue_FileEdits(edits, oldPath, newPath);
+  }
+
+  public static FileEdits createFromJgitEdits(
+      ImmutableList<org.eclipse.jgit.diff.Edit> jgitEdits,
+      Optional<String> oldPath,
+      Optional<String> newPath) {
+    return new AutoValue_FileEdits(
+        jgitEdits.stream().map(Edit::fromJGitEdit).collect(toImmutableList()), oldPath, newPath);
+  }
+
+  public abstract ImmutableList<Edit> edits();
+
+  public abstract Optional<String> oldPath();
+
+  public abstract Optional<String> newPath();
+
+  public static FileEdits empty() {
+    return new AutoValue_FileEdits(ImmutableList.of(), Optional.empty(), Optional.empty());
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/filediff/FileSizeEvaluator.java b/java/com/google/gerrit/server/patch/filediff/FileSizeEvaluator.java
new file mode 100644
index 0000000..97b55dc
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/FileSizeEvaluator.java
@@ -0,0 +1,95 @@
+// 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.filediff;
+
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Patch.FileMode;
+import com.google.gerrit.exceptions.StorageException;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Optional;
+import org.eclipse.jgit.lib.AbbreviatedObjectId;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.treewalk.TreeWalk;
+
+/** Helper class for computing the size of a file in a given git tree. */
+class FileSizeEvaluator {
+  private final ObjectReader reader;
+  private final RevTree tree;
+
+  FileSizeEvaluator(ObjectReader reader, RevTree tree) {
+    this.reader = reader;
+    this.tree = tree;
+  }
+
+  /**
+   * Computes the file size identified by the {@code path} parameter at the given git tree
+   * identified by {@code gitTreeId}.
+   */
+  long compute(AbbreviatedObjectId gitTreeId, Patch.FileMode mode, String path) throws IOException {
+    if (!isBlob(mode)) {
+      return 0;
+    }
+    ObjectId fileId =
+        toObjectId(reader, gitTreeId).orElseGet(() -> lookupObjectId(reader, path, tree));
+    if (ObjectId.zeroId().equals(fileId)) {
+      return 0;
+    }
+    return reader.getObjectSize(fileId, OBJ_BLOB);
+  }
+
+  private static ObjectId lookupObjectId(ObjectReader reader, String path, RevTree tree) {
+    // This variant is very expensive.
+    try (TreeWalk treeWalk = TreeWalk.forPath(reader, path, tree)) {
+      return treeWalk != null ? treeWalk.getObjectId(0) : ObjectId.zeroId();
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  private static Optional<ObjectId> toObjectId(
+      ObjectReader reader, @Nullable AbbreviatedObjectId abbreviatedId) throws IOException {
+    if (abbreviatedId == null) {
+      // In theory, DiffEntry#getOldId or DiffEntry#getNewId can be null for pure renames or pure
+      // mode changes (e.g. DiffEntry#modify doesn't set the IDs). However, the method we call for
+      // diffs (DiffFormatter#scan) seems to always produce DiffEntries with set IDs, even for pure
+      // renames.
+      return Optional.empty();
+    }
+    if (abbreviatedId.isComplete()) {
+      // With the current JGit version and the method we call for diffs (DiffFormatter#scan),
+      // this
+      // is the only code path taken right now.
+      return Optional.ofNullable(abbreviatedId.toObjectId());
+    }
+    Collection<ObjectId> objectIds = reader.resolve(abbreviatedId);
+    // It seems very unlikely that an ObjectId which was just abbreviated by the diff
+    // computation
+    // now can't be resolved to exactly one ObjectId. The API allows this possibility, though.
+    return objectIds.size() == 1
+        ? Optional.of(Iterables.getOnlyElement(objectIds))
+        : Optional.empty();
+  }
+
+  private static boolean isBlob(Patch.FileMode mode) {
+    return mode.equals(FileMode.REGULAR_FILE) || mode.equals(FileMode.SYMLINK);
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/filediff/GitDiffEntity.java b/java/com/google/gerrit/server/patch/filediff/GitDiffEntity.java
new file mode 100644
index 0000000..2ca8fa6
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/GitDiffEntity.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch.filediff;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiff;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheKey;
+
+/** An entity containing a {@link GitFileDiffCacheKey} and its loaded value {@link GitFileDiff}. */
+@AutoValue
+abstract class GitDiffEntity {
+  public static GitDiffEntity create(GitFileDiffCacheKey gitKey, GitFileDiff gitDiff) {
+    return new AutoValue_GitDiffEntity(gitKey, gitDiff);
+  }
+
+  abstract GitFileDiffCacheKey gitKey();
+
+  abstract GitFileDiff gitDiff();
+}
diff --git a/java/com/google/gerrit/server/patch/PatchListLoader.java b/java/com/google/gerrit/server/patch/filediff/PatchListLoader.java
similarity index 89%
rename from java/com/google/gerrit/server/patch/PatchListLoader.java
rename to java/com/google/gerrit/server/patch/filediff/PatchListLoader.java
index be0895b..d1c0b45 100644
--- a/java/com/google/gerrit/server/patch/PatchListLoader.java
+++ b/java/com/google/gerrit/server/patch/filediff/PatchListLoader.java
@@ -1,19 +1,21 @@
-// Copyright (C) 2009 The Android Open Source Project
+//  Copyright (C) 2020 The Android Open Source Project
 //
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
 //
-// http://www.apache.org/licenses/LICENSE-2.0
+//  http://www.apache.org/licenses/LICENSE-2.0
 //
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT 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;
+package com.google.gerrit.server.patch.filediff;
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.collect.ImmutableList.toImmutableList;
@@ -38,7 +40,17 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.patch.EditTransformer.ContextAwareEdit;
+import com.google.gerrit.server.patch.AutoMerger;
+import com.google.gerrit.server.patch.ComparisonType;
+import com.google.gerrit.server.patch.DiffExecutor;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListCacheImpl;
+import com.google.gerrit.server.patch.PatchListEntry;
+import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.Text;
+import com.google.gerrit.server.patch.filediff.EditTransformer.ContextAwareEdit;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -81,7 +93,7 @@
 import org.eclipse.jgit.util.io.DisabledOutputStream;
 
 public class PatchListLoader implements Callable<PatchList> {
-  static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  public static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
     PatchListLoader create(PatchListKey key, Project.NameKey project);
@@ -306,13 +318,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.createFromJgitEdits(patchListEntry.getEdits(), oldName, newName);
+  }
+
   private static boolean isRootOrMergeCommit(RevCommit commit) {
     return commit.getParentCount() != 1;
   }
@@ -405,7 +443,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 +649,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/filediff/TaggedEdit.java b/java/com/google/gerrit/server/patch/filediff/TaggedEdit.java
new file mode 100644
index 0000000..aef2f63
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/TaggedEdit.java
@@ -0,0 +1,33 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch.filediff;
+
+import com.google.auto.value.AutoValue;
+
+/**
+ * An entity class encapsulating a JGit {@link Edit} along with extra attributes (e.g. identifying a
+ * rebase edit).
+ */
+@AutoValue
+public abstract class TaggedEdit {
+
+  public static TaggedEdit create(Edit edit, boolean dueToRebase) {
+    return new AutoValue_TaggedEdit(edit, dueToRebase);
+  }
+
+  abstract Edit edit();
+
+  abstract boolean dueToRebase();
+}
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCache.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCache.java
new file mode 100644
index 0000000..d178f22
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCache.java
@@ -0,0 +1,40 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch.gitdiff;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.diff.ModifiedFilesCache;
+
+/**
+ * A cache interface for identifying the list of Git modified files between 2 different git trees.
+ * This cache does not read the actual file contents, nor does it include the edits (modified
+ * regions) of the file.
+ *
+ * <p>The other {@link ModifiedFilesCache} is similar to this cache, and includes other extra Gerrit
+ * logic that we need to add with the list of modified files.
+ */
+public interface GitModifiedFilesCache {
+
+  /**
+   * Computes the list of of {@link ModifiedFile}s between the 2 git trees.
+   *
+   * @param key used to identify two git trees and contains other attributes to control the diff
+   *     calculation.
+   * @return the list of {@link ModifiedFile}s between the 2 git trees identified by the key.
+   * @throws DiffNotAvailableException trees cannot be read or file contents cannot be read.
+   */
+  ImmutableList<ModifiedFile> get(GitModifiedFilesCacheKey key) throws DiffNotAvailableException;
+}
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java
new file mode 100644
index 0000000..b3b82bb
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java
@@ -0,0 +1,177 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch.gitdiff;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.proto.Cache.ModifiedFilesProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.TypeLiteral;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffEntry.ChangeType;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.util.io.DisabledOutputStream;
+
+/** Implementation of the {@link GitModifiedFilesCache} */
+public class GitModifiedFilesCacheImpl implements GitModifiedFilesCache {
+  private static final String GIT_MODIFIED_FILES = "git_modified_files";
+  private static final ImmutableMap<ChangeType, Patch.ChangeType> changeTypeMap =
+      ImmutableMap.of(
+          DiffEntry.ChangeType.ADD,
+          Patch.ChangeType.ADDED,
+          DiffEntry.ChangeType.MODIFY,
+          Patch.ChangeType.MODIFIED,
+          DiffEntry.ChangeType.DELETE,
+          Patch.ChangeType.DELETED,
+          DiffEntry.ChangeType.RENAME,
+          Patch.ChangeType.RENAMED,
+          DiffEntry.ChangeType.COPY,
+          Patch.ChangeType.COPIED);
+
+  private LoadingCache<GitModifiedFilesCacheKey, ImmutableList<ModifiedFile>> cache;
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        bind(GitModifiedFilesCache.class).to(GitModifiedFilesCacheImpl.class);
+
+        persist(
+                GIT_MODIFIED_FILES,
+                GitModifiedFilesCacheKey.class,
+                new TypeLiteral<ImmutableList<ModifiedFile>>() {})
+            .keySerializer(GitModifiedFilesCacheKey.Serializer.INSTANCE)
+            .valueSerializer(ValueSerializer.INSTANCE)
+            // The documentation has some defaults and recommendations for setting the cache
+            // attributes:
+            // https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#cache.
+            .maximumWeight(10 << 20)
+            .weigher(GitModifiedFilesWeigher.class)
+            // The cache is using the default disk limit as per section cache.<name>.diskLimit
+            // in the cache documentation link.
+            .version(1)
+            .loader(GitModifiedFilesCacheImpl.Loader.class);
+      }
+    };
+  }
+
+  @Inject
+  public GitModifiedFilesCacheImpl(
+      @Named(GIT_MODIFIED_FILES)
+          LoadingCache<GitModifiedFilesCacheKey, ImmutableList<ModifiedFile>> cache) {
+    this.cache = cache;
+  }
+
+  @Override
+  public ImmutableList<ModifiedFile> get(GitModifiedFilesCacheKey key)
+      throws DiffNotAvailableException {
+    try {
+      return cache.get(key);
+    } catch (ExecutionException e) {
+      throw new DiffNotAvailableException(e);
+    }
+  }
+
+  static class Loader extends CacheLoader<GitModifiedFilesCacheKey, ImmutableList<ModifiedFile>> {
+    private final GitRepositoryManager repoManager;
+
+    @Inject
+    Loader(GitRepositoryManager repoManager) {
+      this.repoManager = repoManager;
+    }
+
+    @Override
+    public ImmutableList<ModifiedFile> load(GitModifiedFilesCacheKey key) throws IOException {
+      try (Repository repo = repoManager.openRepository(key.project());
+          ObjectReader reader = repo.newObjectReader()) {
+        List<DiffEntry> entries = getGitTreeDiff(repo, reader, key);
+
+        return entries.stream().map(Loader::toModifiedFile).collect(toImmutableList());
+      }
+    }
+
+    private List<DiffEntry> getGitTreeDiff(
+        Repository repo, ObjectReader reader, GitModifiedFilesCacheKey key) throws IOException {
+      try (DiffFormatter df = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
+        df.setReader(reader, repo.getConfig());
+        if (key.renameDetection()) {
+          df.setDetectRenames(true);
+          df.getRenameDetector().setRenameScore(key.renameScore());
+        }
+        // The scan method only returns the file paths that are different. Callers may choose to
+        // format these paths themselves.
+        return df.scan(key.aTree(), key.bTree());
+      }
+    }
+
+    private static ModifiedFile toModifiedFile(DiffEntry entry) {
+      String oldPath = entry.getOldPath();
+      String newPath = entry.getNewPath();
+      return ModifiedFile.builder()
+          .changeType(toChangeType(entry.getChangeType()))
+          .oldPath(oldPath.equals(DiffEntry.DEV_NULL) ? Optional.empty() : Optional.of(oldPath))
+          .newPath(newPath.equals(DiffEntry.DEV_NULL) ? Optional.empty() : Optional.of(newPath))
+          .build();
+    }
+
+    private static Patch.ChangeType toChangeType(DiffEntry.ChangeType changeType) {
+      if (!changeTypeMap.containsKey(changeType)) {
+        throw new IllegalArgumentException("Unsupported type " + changeType);
+      }
+      return changeTypeMap.get(changeType);
+    }
+  }
+
+  public enum ValueSerializer implements CacheSerializer<ImmutableList<ModifiedFile>> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(ImmutableList<ModifiedFile> modifiedFiles) {
+      ModifiedFilesProto.Builder builder = ModifiedFilesProto.newBuilder();
+      modifiedFiles.forEach(
+          f -> builder.addModifiedFile(ModifiedFile.Serializer.INSTANCE.toProto(f)));
+      return Protos.toByteArray(builder.build());
+    }
+
+    @Override
+    public ImmutableList<ModifiedFile> deserialize(byte[] in) {
+      ImmutableList.Builder<ModifiedFile> modifiedFiles = ImmutableList.builder();
+      ModifiedFilesProto modifiedFilesProto =
+          Protos.parseUnchecked(ModifiedFilesProto.parser(), in);
+      modifiedFilesProto
+          .getModifiedFileList()
+          .forEach(f -> modifiedFiles.add(ModifiedFile.Serializer.INSTANCE.fromProto(f)));
+      return modifiedFiles.build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheKey.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheKey.java
new file mode 100644
index 0000000..fb8fce1
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheKey.java
@@ -0,0 +1,129 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch.gitdiff;
+
+import static com.google.gerrit.server.patch.DiffUtil.stringSize;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache.GitModifiedFilesKeyProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import com.google.gerrit.server.patch.DiffUtil;
+import java.io.IOException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** Cache key for the {@link GitModifiedFilesCache}. */
+@AutoValue
+public abstract class GitModifiedFilesCacheKey {
+
+  /** A specific git project / repository. */
+  public abstract Project.NameKey project();
+
+  /**
+   * The git SHA-1 {@link ObjectId} of the first git tree object for which the diff should be
+   * computed.
+   */
+  public abstract ObjectId aTree();
+
+  /**
+   * The git SHA-1 {@link ObjectId} of the second git tree object for which the diff should be
+   * computed.
+   */
+  public abstract ObjectId bTree();
+
+  /**
+   * Percentage score used to identify a file as a rename. This value is only available if {@link
+   * #renameDetection()} is true. Otherwise, this method will return -1.
+   *
+   * <p>This value will be used to set the rename score of {@link
+   * org.eclipse.jgit.diff.DiffFormatter#getRenameDetector()}.
+   */
+  public abstract int renameScore();
+
+  /** Returns true if rename detection was set for this key. */
+  public boolean renameDetection() {
+    return renameScore() != -1;
+  }
+
+  public static GitModifiedFilesCacheKey create(
+      Project.NameKey project, ObjectId aCommit, ObjectId bCommit, int renameScore, RevWalk rw)
+      throws IOException {
+    ObjectId aTree = DiffUtil.getTreeId(rw, aCommit);
+    ObjectId bTree = DiffUtil.getTreeId(rw, bCommit);
+    return builder().project(project).aTree(aTree).bTree(bTree).renameScore(renameScore).build();
+  }
+
+  public static Builder builder() {
+    return new AutoValue_GitModifiedFilesCacheKey.Builder();
+  }
+
+  /** Returns the size of the object in bytes */
+  public int weight() {
+    return stringSize(project().get())
+        + 20 * 2 // old and new tree IDs
+        + 4; // rename score
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder project(NameKey value);
+
+    public abstract Builder aTree(ObjectId value);
+
+    public abstract Builder bTree(ObjectId value);
+
+    public abstract Builder renameScore(int value);
+
+    public Builder disableRenameDetection() {
+      renameScore(-1);
+      return this;
+    }
+
+    public abstract GitModifiedFilesCacheKey build();
+  }
+
+  public enum Serializer implements CacheSerializer<GitModifiedFilesCacheKey> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(GitModifiedFilesCacheKey key) {
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      return Protos.toByteArray(
+          GitModifiedFilesKeyProto.newBuilder()
+              .setProject(key.project().get())
+              .setATree(idConverter.toByteString(key.aTree()))
+              .setBTree(idConverter.toByteString(key.bTree()))
+              .setRenameScore(key.renameScore())
+              .build());
+    }
+
+    @Override
+    public GitModifiedFilesCacheKey deserialize(byte[] in) {
+      GitModifiedFilesKeyProto proto = Protos.parseUnchecked(GitModifiedFilesKeyProto.parser(), in);
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      return GitModifiedFilesCacheKey.builder()
+          .project(NameKey.parse(proto.getProject()))
+          .aTree(idConverter.fromByteString(proto.getATree()))
+          .bTree(idConverter.fromByteString(proto.getBTree()))
+          .renameScore(proto.getRenameScore())
+          .build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesWeigher.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesWeigher.java
new file mode 100644
index 0000000..a678379
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesWeigher.java
@@ -0,0 +1,26 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch.gitdiff;
+
+import com.google.common.cache.Weigher;
+import com.google.common.collect.ImmutableList;
+
+public class GitModifiedFilesWeigher
+    implements Weigher<GitModifiedFilesCacheKey, ImmutableList<ModifiedFile>> {
+  @Override
+  public int weigh(GitModifiedFilesCacheKey key, ImmutableList<ModifiedFile> modifiedFiles) {
+    return key.weight() + modifiedFiles.stream().mapToInt(ModifiedFile::weight).sum();
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java b/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
new file mode 100644
index 0000000..9512094
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
@@ -0,0 +1,123 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch.gitdiff;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache.ModifiedFileProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.protobuf.Descriptors.FieldDescriptor;
+import java.util.Optional;
+
+/**
+ * An entity representing a Modified file due to a diff between 2 git trees. This entity contains
+ * the change type and the old & new paths, but does not include any actual content diff of the
+ * file.
+ */
+@AutoValue
+public abstract class ModifiedFile {
+  /**
+   * Returns the change type (i.e. add, delete, modify, rename, etc...) associated with this
+   * modified file.
+   */
+  public abstract ChangeType changeType();
+
+  /**
+   * Returns the old name associated with this file. An empty optional is returned if {@link
+   * #changeType()} is equal to {@link ChangeType#ADDED}.
+   */
+  public abstract Optional<String> oldPath();
+
+  /**
+   * Returns the new name associated with this file. An empty optional is returned if {@link
+   * #changeType()} is equal to {@link ChangeType#DELETED}
+   */
+  public abstract Optional<String> newPath();
+
+  public static Builder builder() {
+    return new AutoValue_ModifiedFile.Builder();
+  }
+
+  /** Computes this object's weight, which is its size in bytes. */
+  public int weight() {
+    int weight = 1; // the changeType field
+    if (oldPath().isPresent()) {
+      weight += oldPath().get().length();
+    }
+    if (newPath().isPresent()) {
+      weight += newPath().get().length();
+    }
+    return weight;
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder changeType(ChangeType value);
+
+    public abstract Builder oldPath(Optional<String> value);
+
+    public abstract Builder newPath(Optional<String> value);
+
+    public abstract ModifiedFile build();
+  }
+
+  enum Serializer implements CacheSerializer<ModifiedFile> {
+    INSTANCE;
+
+    private static final FieldDescriptor oldPathDescriptor =
+        ModifiedFileProto.getDescriptor().findFieldByNumber(2);
+
+    private static final FieldDescriptor newPathDescriptor =
+        ModifiedFileProto.getDescriptor().findFieldByNumber(3);
+
+    @Override
+    public byte[] serialize(ModifiedFile modifiedFile) {
+      return Protos.toByteArray(toProto(modifiedFile));
+    }
+
+    public ModifiedFileProto toProto(ModifiedFile modifiedFile) {
+      ModifiedFileProto.Builder builder = ModifiedFileProto.newBuilder();
+      builder.setChangeType(modifiedFile.changeType().toString());
+      if (modifiedFile.oldPath().isPresent()) {
+        builder.setOldPath(modifiedFile.oldPath().get());
+      }
+      if (modifiedFile.newPath().isPresent()) {
+        builder.setNewPath(modifiedFile.newPath().get());
+      }
+      return builder.build();
+    }
+
+    @Override
+    public ModifiedFile deserialize(byte[] in) {
+      ModifiedFileProto modifiedFileProto = Protos.parseUnchecked(ModifiedFileProto.parser(), in);
+      return fromProto(modifiedFileProto);
+    }
+
+    public ModifiedFile fromProto(ModifiedFileProto modifiedFileProto) {
+      ModifiedFile.Builder builder = ModifiedFile.builder();
+      builder.changeType(ChangeType.valueOf(modifiedFileProto.getChangeType()));
+
+      if (modifiedFileProto.hasField(oldPathDescriptor)) {
+        builder.oldPath(Optional.of(modifiedFileProto.getOldPath()));
+      }
+      if (modifiedFileProto.hasField(newPathDescriptor)) {
+        builder.newPath(Optional.of(modifiedFileProto.getNewPath()));
+      }
+      return builder.build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/FileHeaderUtil.java b/java/com/google/gerrit/server/patch/gitfilediff/FileHeaderUtil.java
new file mode 100644
index 0000000..7454f81
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitfilediff/FileHeaderUtil.java
@@ -0,0 +1,198 @@
+//  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.gitfilediff;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Patch.PatchType;
+import java.util.Optional;
+import org.eclipse.jgit.patch.CombinedFileHeader;
+import org.eclipse.jgit.patch.FileHeader;
+import org.eclipse.jgit.util.IntList;
+import org.eclipse.jgit.util.RawParseUtils;
+
+/** A utility class for the {@link FileHeader} JGit object */
+public class FileHeaderUtil {
+  private static final Byte NUL = '\0';
+
+  /**
+   * The maximum number of characters to lookup in the binary file {@link FileHeader}. This is used
+   * to scan the file header for the occurrence of the {@link #NUL} character.
+   *
+   * <p>This limit assumes a uniform distribution of all characters, hence the probability of the
+   * occurrence of each character = (1 / 256). We want to find the limit that makes the prob. of
+   * finding {@link #NUL} > 0.999. 1 - (255 / 256) ^ N > 0.999 yields N = 1766. We set the limit to
+   * this value multiplied by 10 for more confidence.
+   */
+  private static final int BIN_FILE_MAX_SCAN_LIMIT = 20000;
+
+  /** Converts the {@link FileHeader} parameter to a String representation. */
+  static String toString(FileHeader header) {
+    return new String(FileHeaderUtil.toByteArray(header), UTF_8);
+  }
+
+  /** Converts the {@link FileHeader} parameter to a byte array. */
+  static byte[] toByteArray(FileHeader header) {
+    int end = getEndOffset(header);
+    if (header.getStartOffset() == 0 && end == header.getBuffer().length) {
+      return header.getBuffer();
+    }
+
+    final byte[] buf = new byte[end - header.getStartOffset()];
+    System.arraycopy(header.getBuffer(), header.getStartOffset(), buf, 0, buf.length);
+    return buf;
+  }
+
+  /** Splits the {@code FileHeader} string to a list of strings, one string per header line. */
+  public static ImmutableList<String> getHeaderLines(FileHeader fileHeader) {
+    String fileHeaderString = toString(fileHeader);
+    return getHeaderLines(fileHeaderString);
+  }
+
+  public static ImmutableList<String> getHeaderLines(String header) {
+    return getHeaderLines(header.getBytes(UTF_8));
+  }
+
+  static ImmutableList<String> getHeaderLines(byte[] header) {
+    final IntList lineStartOffsets = RawParseUtils.lineMap(header, 0, header.length);
+    final ImmutableList.Builder<String> headerLines =
+        ImmutableList.builderWithExpectedSize(lineStartOffsets.size() - 1);
+    for (int i = 1; i < lineStartOffsets.size() - 1; i++) {
+      final int b = lineStartOffsets.get(i);
+      int e = lineStartOffsets.get(i + 1);
+      if (header[e - 1] == '\n') {
+        e--;
+      }
+      headerLines.add(RawParseUtils.decode(UTF_8, header, b, e));
+    }
+    return headerLines.build();
+  }
+
+  /**
+   * Returns the old file path associated with the {@link FileHeader}, or empty if the file is
+   * {@link com.google.gerrit.entities.Patch.ChangeType#ADDED} or {@link
+   * com.google.gerrit.entities.Patch.ChangeType#REWRITE}.
+   */
+  public static Optional<String> getOldPath(FileHeader header) {
+    Patch.ChangeType changeType = getChangeType(header);
+    switch (changeType) {
+      case DELETED:
+      case COPIED:
+      case RENAMED:
+      case MODIFIED:
+        return Optional.of(header.getOldPath());
+
+      case ADDED:
+      case REWRITE:
+        return Optional.empty();
+    }
+    return Optional.empty();
+  }
+
+  /**
+   * Returns the new file path associated with the {@link FileHeader}, or empty if the file is
+   * {@link com.google.gerrit.entities.Patch.ChangeType#DELETED}.
+   */
+  public static Optional<String> getNewPath(FileHeader header) {
+    Patch.ChangeType changeType = getChangeType(header);
+    switch (changeType) {
+      case DELETED:
+        return Optional.empty();
+
+      case ADDED:
+      case MODIFIED:
+      case REWRITE:
+      case COPIED:
+      case RENAMED:
+        return Optional.of(header.getNewPath());
+    }
+    return Optional.empty();
+  }
+
+  /** Returns the change type associated with the file header. */
+  public static Patch.ChangeType getChangeType(FileHeader header) {
+    // In Gerrit, we define our own entities  of the JGit entities, so that we have full control
+    // over their behaviors (e.g. making sure that these entities are immutable so that we can add
+    // them as fields of keys / values of persisted caches).
+
+    // TODO(ghareeb): remove the dead code of the value REWRITE and all its handling
+    switch (header.getChangeType()) {
+      case ADD:
+        return Patch.ChangeType.ADDED;
+      case MODIFY:
+        return Patch.ChangeType.MODIFIED;
+      case DELETE:
+        return Patch.ChangeType.DELETED;
+      case RENAME:
+        return Patch.ChangeType.RENAMED;
+      case COPY:
+        return Patch.ChangeType.COPIED;
+      default:
+        throw new IllegalArgumentException("Unsupported type " + header.getChangeType());
+    }
+  }
+
+  public static PatchType getPatchType(FileHeader header) {
+    PatchType patchType;
+
+    switch (header.getPatchType()) {
+      case UNIFIED:
+        patchType = Patch.PatchType.UNIFIED;
+        break;
+      case GIT_BINARY:
+      case BINARY:
+        patchType = Patch.PatchType.BINARY;
+        break;
+      default:
+        throw new IllegalArgumentException("Unsupported type " + header.getPatchType());
+    }
+
+    if (patchType != PatchType.BINARY) {
+      byte[] buf = header.getBuffer();
+      // TODO(ghareeb): should we adjust the max limit threshold?
+      // JGit sometimes misses the detection of binary files. In this case we look into the file
+      // header for the occurrence of NUL characters, which is a definite signal that the file is
+      // binary. We limit the number of characters to lookup to avoid performance bottlenecks.
+      for (int ptr = header.getStartOffset();
+          ptr < Math.min(header.getEndOffset(), BIN_FILE_MAX_SCAN_LIMIT);
+          ptr++) {
+        if (buf[ptr] == NUL) {
+          // It's really binary, but Git couldn't see the nul early enough to realize its binary,
+          // and instead produced the diff.
+          //
+          // Force it to be a binary; it really should have been that.
+          return PatchType.BINARY;
+        }
+      }
+    }
+    return patchType;
+  }
+
+  /**
+   * Returns the end offset of the diff header line of the {@code FileHeader parameter} before the
+   * appearance of any file edits (diff hunks).
+   */
+  private static int getEndOffset(FileHeader fileHeader) {
+    if (fileHeader instanceof CombinedFileHeader) {
+      return fileHeader.getEndOffset();
+    }
+    if (!fileHeader.getHunks().isEmpty()) {
+      return fileHeader.getHunks().get(0).getStartOffset();
+    }
+    return fileHeader.getEndOffset();
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
new file mode 100644
index 0000000..a01d447
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
@@ -0,0 +1,288 @@
+//  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.gitfilediff;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.server.patch.DiffUtil.stringSize;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.Patch.PatchType;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache.GitFileDiffProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import com.google.gerrit.server.patch.filediff.Edit;
+import com.google.protobuf.Descriptors.FieldDescriptor;
+import java.io.IOException;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.lib.AbbreviatedObjectId;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.patch.FileHeader;
+
+/**
+ * Entity representing a modified file (added, deleted, modified, renamed, etc...) between two
+ * different git commits.
+ */
+@AutoValue
+public abstract class GitFileDiff {
+  private static final Map<FileMode, Patch.FileMode> fileModeMap =
+      ImmutableMap.<FileMode, Patch.FileMode>builder()
+          .put(FileMode.TREE, Patch.FileMode.TREE)
+          .put(FileMode.SYMLINK, Patch.FileMode.SYMLINK)
+          .put(FileMode.GITLINK, Patch.FileMode.GITLINK)
+          .put(FileMode.REGULAR_FILE, Patch.FileMode.REGULAR_FILE)
+          .put(FileMode.EXECUTABLE_FILE, Patch.FileMode.EXECUTABLE_FILE)
+          .put(FileMode.MISSING, Patch.FileMode.MISSING)
+          .build();
+
+  private static Patch.FileMode mapFileMode(FileMode jgitFileMode) {
+    if (!fileModeMap.containsKey(jgitFileMode)) {
+      throw new IllegalArgumentException("Unsupported type " + jgitFileMode);
+    }
+    return fileModeMap.get(jgitFileMode);
+  }
+
+  /**
+   * Creates a {@link GitFileDiff} using the {@code diffEntry} and the {@code diffFormatter}
+   * parameters.
+   */
+  static GitFileDiff create(DiffEntry diffEntry, DiffFormatter diffFormatter) throws IOException {
+    FileHeader fileHeader = diffFormatter.toFileHeader(diffEntry);
+    ImmutableList<Edit> edits =
+        fileHeader.toEditList().stream().map(Edit::fromJGitEdit).collect(toImmutableList());
+
+    return builder()
+        .edits(edits)
+        .oldId(diffEntry.getOldId())
+        .newId(diffEntry.getNewId())
+        .fileHeader(FileHeaderUtil.toString(fileHeader))
+        .oldPath(FileHeaderUtil.getOldPath(fileHeader))
+        .newPath(FileHeaderUtil.getNewPath(fileHeader))
+        .changeType(FileHeaderUtil.getChangeType(fileHeader))
+        .patchType(Optional.of(FileHeaderUtil.getPatchType(fileHeader)))
+        .oldMode(Optional.of(mapFileMode(diffEntry.getOldMode())))
+        .newMode(Optional.of(mapFileMode(diffEntry.getNewMode())))
+        .build();
+  }
+
+  /**
+   * Represents an empty file diff, which means that the file was not modified between the two git
+   * trees identified by {@link #oldId()} and {@link #newId()}.
+   *
+   * @param newFilePath the file name at the {@link #newId()} git tree.
+   */
+  static GitFileDiff empty(
+      AbbreviatedObjectId oldId, AbbreviatedObjectId newId, String newFilePath) {
+    return builder()
+        .oldId(oldId)
+        .newId(newId)
+        .newPath(Optional.of(newFilePath))
+        .changeType(ChangeType.MODIFIED)
+        .edits(ImmutableList.of())
+        .fileHeader("")
+        .build();
+  }
+
+  /** An {@link ImmutableList} of the modified regions in the file. */
+  public abstract ImmutableList<Edit> edits();
+
+  /** A string representation of the {@link org.eclipse.jgit.patch.FileHeader}. */
+  public abstract String fileHeader();
+
+  /** The file name at the old git tree identified by {@link #oldId()} */
+  public abstract Optional<String> oldPath();
+
+  /** The file name at the new git tree identified by {@link #newId()} */
+  public abstract Optional<String> newPath();
+
+  /** The 20 bytes SHA-1 object ID of the old git tree of the diff. */
+  public abstract AbbreviatedObjectId oldId();
+
+  /** The 20 bytes SHA-1 object ID of the new git tree of the diff. */
+  public abstract AbbreviatedObjectId newId();
+
+  /** The file mode of the old file at the old git tree diff identified by {@link #oldId()}. */
+  public abstract Optional<Patch.FileMode> oldMode();
+
+  /** The file mode of the new file at the new git tree diff identified by {@link #newId()}. */
+  public abstract Optional<Patch.FileMode> newMode();
+
+  /** The change type associated with the file. */
+  public abstract ChangeType changeType();
+
+  /** The patch type associated with the file. */
+  public abstract Optional<PatchType> patchType();
+
+  /**
+   * Returns true if the object was created using the {@link #empty(AbbreviatedObjectId,
+   * AbbreviatedObjectId, String)} method.
+   */
+  public boolean isEmpty() {
+    return edits().isEmpty();
+  }
+
+  /** Returns the size of the object in bytes. */
+  public int weight() {
+    int result = 20 * 2; // oldId and newId
+    result += 16 * edits().size(); // each edit contains 4 integers (hence 16 bytes)
+    result += stringSize(fileHeader());
+    if (oldPath().isPresent()) {
+      result += stringSize(oldPath().get());
+    }
+    if (newPath().isPresent()) {
+      result += stringSize(newPath().get());
+    }
+    result += 4;
+    if (patchType().isPresent()) {
+      result += 4;
+    }
+    if (oldMode().isPresent()) {
+      result += 4;
+    }
+    if (newMode().isPresent()) {
+      result += 4;
+    }
+    return result;
+  }
+
+  public static Builder builder() {
+    return new AutoValue_GitFileDiff.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder edits(ImmutableList<Edit> value);
+
+    public abstract Builder fileHeader(String value);
+
+    public abstract Builder oldPath(Optional<String> value);
+
+    public abstract Builder newPath(Optional<String> value);
+
+    public abstract Builder oldId(AbbreviatedObjectId value);
+
+    public abstract Builder newId(AbbreviatedObjectId value);
+
+    public abstract Builder oldMode(Optional<Patch.FileMode> value);
+
+    public abstract Builder newMode(Optional<Patch.FileMode> value);
+
+    public abstract Builder changeType(ChangeType value);
+
+    public abstract Builder patchType(Optional<PatchType> value);
+
+    public abstract GitFileDiff build();
+  }
+
+  public enum Serializer implements CacheSerializer<GitFileDiff> {
+    INSTANCE;
+
+    private static final FieldDescriptor OLD_PATH_DESCRIPTOR =
+        GitFileDiffProto.getDescriptor().findFieldByNumber(3);
+
+    private static final FieldDescriptor NEW_PATH_DESCRIPTOR =
+        GitFileDiffProto.getDescriptor().findFieldByNumber(4);
+
+    private static final FieldDescriptor OLD_MODE_DESCRIPTOR =
+        GitFileDiffProto.getDescriptor().findFieldByNumber(7);
+
+    private static final FieldDescriptor NEW_MODE_DESCRIPTOR =
+        GitFileDiffProto.getDescriptor().findFieldByNumber(8);
+
+    private static final FieldDescriptor CHANGE_TYPE_DESCRIPTOR =
+        GitFileDiffProto.getDescriptor().findFieldByNumber(9);
+
+    private static final FieldDescriptor PATCH_TYPE_DESCRIPTOR =
+        GitFileDiffProto.getDescriptor().findFieldByNumber(10);
+
+    @Override
+    public byte[] serialize(GitFileDiff gitFileDiff) {
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      GitFileDiffProto.Builder builder =
+          GitFileDiffProto.newBuilder()
+              .setFileHeader(gitFileDiff.fileHeader())
+              .setOldId(idConverter.toByteString(gitFileDiff.oldId().toObjectId()))
+              .setNewId(idConverter.toByteString(gitFileDiff.newId().toObjectId()))
+              .setChangeType(gitFileDiff.changeType().name());
+      gitFileDiff
+          .edits()
+          .forEach(
+              e ->
+                  builder.addEdits(
+                      GitFileDiffProto.Edit.newBuilder()
+                          .setBeginA(e.beginA())
+                          .setEndA(e.endA())
+                          .setBeginB(e.beginB())
+                          .setEndB(e.endB())));
+      if (gitFileDiff.oldPath().isPresent()) {
+        builder.setOldPath(gitFileDiff.oldPath().get());
+      }
+      if (gitFileDiff.newPath().isPresent()) {
+        builder.setNewPath(gitFileDiff.newPath().get());
+      }
+      if (gitFileDiff.oldMode().isPresent()) {
+        builder.setOldMode(gitFileDiff.oldMode().get().name());
+      }
+      if (gitFileDiff.newMode().isPresent()) {
+        builder.setNewMode(gitFileDiff.newMode().get().name());
+      }
+      if (gitFileDiff.patchType().isPresent()) {
+        builder.setPatchType(gitFileDiff.patchType().get().name());
+      }
+      return Protos.toByteArray(builder.build());
+    }
+
+    @Override
+    public GitFileDiff deserialize(byte[] in) {
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      GitFileDiffProto proto = Protos.parseUnchecked(GitFileDiffProto.parser(), in);
+      GitFileDiff.Builder builder = GitFileDiff.builder();
+      builder
+          .edits(
+              proto.getEditsList().stream()
+                  .map(e -> Edit.create(e.getBeginA(), e.getEndA(), e.getBeginB(), e.getEndB()))
+                  .collect(toImmutableList()))
+          .fileHeader(proto.getFileHeader())
+          .oldId(AbbreviatedObjectId.fromObjectId(idConverter.fromByteString(proto.getOldId())))
+          .newId(AbbreviatedObjectId.fromObjectId(idConverter.fromByteString(proto.getNewId())))
+          .changeType(ChangeType.valueOf(proto.getChangeType()));
+
+      if (proto.hasField(OLD_PATH_DESCRIPTOR)) {
+        builder.oldPath(Optional.of(proto.getOldPath()));
+      }
+      if (proto.hasField(NEW_PATH_DESCRIPTOR)) {
+        builder.newPath(Optional.of(proto.getNewPath()));
+      }
+      if (proto.hasField(OLD_MODE_DESCRIPTOR)) {
+        builder.oldMode(Optional.of(Patch.FileMode.valueOf(proto.getOldMode())));
+      }
+      if (proto.hasField(NEW_MODE_DESCRIPTOR)) {
+        builder.newMode(Optional.of(Patch.FileMode.valueOf(proto.getNewMode())));
+      }
+      if (proto.hasField(PATCH_TYPE_DESCRIPTOR)) {
+        builder.patchType(Optional.of(Patch.PatchType.valueOf(proto.getPatchType())));
+      }
+      return builder.build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCache.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCache.java
new file mode 100644
index 0000000..2516761
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCache.java
@@ -0,0 +1,43 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch.gitfilediff;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+
+/** This cache computes pure git diff for a single file path according to a git tree diff. */
+public interface GitFileDiffCache {
+
+  /**
+   * Returns the git file diff for a single file path identified by its key.
+   *
+   * @param key identifies two git trees, a specific file path and other diff parameters.
+   * @return the file diff for a single file path identified by its key.
+   * @throws DiffNotAvailableException if the tree IDs of the key are invalid for this project or if
+   *     file contents could not be read.
+   */
+  GitFileDiff get(GitFileDiffCacheKey key) throws DiffNotAvailableException;
+
+  /**
+   * Returns the file diff for a collection of file paths identified by their keys.
+   *
+   * @param keys identifying different file paths of different projects.
+   * @return a map of the input keys to their corresponding git file diffs.
+   * @throws DiffNotAvailableException if the diff failed to be evaluated for one or more of the
+   *     input keys due to invalid tree IDs or if file contents could not be read.
+   */
+  ImmutableMap<GitFileDiffCacheKey, GitFileDiff> getAll(Iterable<GitFileDiffCacheKey> keys)
+      throws DiffNotAvailableException;
+}
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
new file mode 100644
index 0000000..97cf37d32
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
@@ -0,0 +1,273 @@
+//  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.gitfilediff;
+
+import static java.util.function.Function.identity;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Streams;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffEntry.ChangeType;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.diff.HistogramDiff;
+import org.eclipse.jgit.diff.RawTextComparator;
+import org.eclipse.jgit.lib.AbbreviatedObjectId;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.util.io.DisabledOutputStream;
+
+/** Implementation of the {@link GitFileDiffCache} */
+public class GitFileDiffCacheImpl implements GitFileDiffCache {
+  private static final String GIT_DIFF = "git_file_diff";
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        bind(GitFileDiffCache.class).to(GitFileDiffCacheImpl.class);
+        persist(GIT_DIFF, GitFileDiffCacheKey.class, GitFileDiff.class)
+            .maximumWeight(10 << 20)
+            .weigher(GitFileDiffWeigher.class)
+            .keySerializer(GitFileDiffCacheKey.Serializer.INSTANCE)
+            .valueSerializer(GitFileDiff.Serializer.INSTANCE)
+            .loader(GitFileDiffCacheImpl.Loader.class);
+      }
+    };
+  }
+
+  /** Enum for the supported diff algorithms for the file diff computation. */
+  public enum DiffAlgorithm {
+    HISTOGRAM,
+    HISTOGRAM_WITHOUT_MYERS_FALLBACK
+  }
+
+  /** Creates a new JGit diff algorithm instance using the Gerrit's {@link DiffAlgorithm} enum. */
+  public static class DiffAlgorithmFactory {
+    public static org.eclipse.jgit.diff.DiffAlgorithm create(DiffAlgorithm diffAlgorithm) {
+      HistogramDiff result = new HistogramDiff();
+      if (diffAlgorithm.equals(DiffAlgorithm.HISTOGRAM_WITHOUT_MYERS_FALLBACK)) {
+        result.setFallbackAlgorithm(null);
+      }
+      return result;
+    }
+  }
+
+  private final LoadingCache<GitFileDiffCacheKey, GitFileDiff> cache;
+
+  @Inject
+  public GitFileDiffCacheImpl(
+      @Named(GIT_DIFF) LoadingCache<GitFileDiffCacheKey, GitFileDiff> cache) {
+    this.cache = cache;
+  }
+
+  @Override
+  public GitFileDiff get(GitFileDiffCacheKey key) throws DiffNotAvailableException {
+    try {
+      return cache.get(key);
+    } catch (ExecutionException e) {
+      throw new DiffNotAvailableException(e);
+    }
+  }
+
+  @Override
+  public ImmutableMap<GitFileDiffCacheKey, GitFileDiff> getAll(Iterable<GitFileDiffCacheKey> keys)
+      throws DiffNotAvailableException {
+    try {
+      return cache.getAll(keys);
+    } catch (ExecutionException e) {
+      throw new DiffNotAvailableException(e);
+    }
+  }
+
+  static class Loader extends CacheLoader<GitFileDiffCacheKey, GitFileDiff> {
+    /**
+     * Extractor for the file path from a {@link DiffEntry}. Returns the old file path if the entry
+     * corresponds to a deleted file, otherwise it returns the new file path.
+     */
+    private static final Function<DiffEntry, String> pathExtractor =
+        (DiffEntry entry) ->
+            entry.getChangeType().equals(ChangeType.DELETE)
+                ? entry.getOldPath()
+                : entry.getNewPath();
+
+    private final GitRepositoryManager repoManager;
+
+    @Inject
+    public Loader(GitRepositoryManager repoManager) {
+      this.repoManager = repoManager;
+    }
+
+    @Override
+    public GitFileDiff load(GitFileDiffCacheKey key) throws IOException {
+      return loadAll(ImmutableList.of(key)).get(key);
+    }
+
+    @Override
+    public Map<GitFileDiffCacheKey, GitFileDiff> loadAll(
+        Iterable<? extends GitFileDiffCacheKey> keys) throws IOException {
+      ImmutableMap.Builder<GitFileDiffCacheKey, GitFileDiff> result =
+          ImmutableMap.builderWithExpectedSize(Iterables.size(keys));
+
+      Map<Project.NameKey, List<GitFileDiffCacheKey>> byProject =
+          Streams.stream(keys)
+              .distinct()
+              .collect(Collectors.groupingBy(GitFileDiffCacheKey::project));
+
+      for (Map.Entry<Project.NameKey, List<GitFileDiffCacheKey>> entry : byProject.entrySet()) {
+        try (Repository repo = repoManager.openRepository(entry.getKey());
+            ObjectReader reader = repo.newObjectReader()) {
+
+          // Grouping keys by diff options because each group of keys will be processed with a
+          // separate call to JGit using the DiffFormatter object.
+          Map<DiffOptions, List<GitFileDiffCacheKey>> optionsGroups =
+              entry.getValue().stream().collect(Collectors.groupingBy(DiffOptions::fromKey));
+
+          for (Map.Entry<DiffOptions, List<GitFileDiffCacheKey>> group : optionsGroups.entrySet()) {
+            result.putAll(loadAllImpl(repo, reader, group.getKey(), group.getValue()));
+          }
+        }
+      }
+      return result.build();
+    }
+
+    /**
+     * Loads the git file diffs for all keys of the same repository, and having the same diff {@code
+     * options}.
+     *
+     * @return The git file diffs for all input keys.
+     */
+    private Map<GitFileDiffCacheKey, GitFileDiff> loadAllImpl(
+        Repository repo, ObjectReader reader, DiffOptions options, List<GitFileDiffCacheKey> keys)
+        throws IOException {
+      ImmutableMap.Builder<GitFileDiffCacheKey, GitFileDiff> result =
+          ImmutableMap.builderWithExpectedSize(keys.size());
+      Map<GitFileDiffCacheKey, String> filePaths =
+          keys.stream().collect(Collectors.toMap(identity(), GitFileDiffCacheKey::newFilePath));
+      DiffFormatter formatter = createDiffFormatter(options, repo, reader);
+      Map<String, DiffEntry> diffEntries = loadDiffEntries(formatter, options, filePaths.values());
+      for (GitFileDiffCacheKey key : filePaths.keySet()) {
+        String newFilePath = filePaths.get(key);
+        if (diffEntries.containsKey(newFilePath)) {
+          result.put(key, GitFileDiff.create(diffEntries.get(newFilePath), formatter));
+          continue;
+        }
+        result.put(
+            key,
+            GitFileDiff.empty(
+                AbbreviatedObjectId.fromObjectId(key.oldTree()),
+                AbbreviatedObjectId.fromObjectId(key.newTree()),
+                newFilePath));
+      }
+      return result.build();
+    }
+
+    private static Map<String, DiffEntry> loadDiffEntries(
+        DiffFormatter diffFormatter, DiffOptions diffOptions, Collection<String> filePaths)
+        throws IOException {
+      Set<String> filePathsSet = ImmutableSet.copyOf(filePaths);
+      List<DiffEntry> diffEntries =
+          diffFormatter.scan(diffOptions.oldTree(), diffOptions.newTree());
+
+      return diffEntries.stream()
+          .filter(d -> filePathsSet.contains(pathExtractor.apply(d)))
+          .collect(Collectors.toMap(d -> pathExtractor.apply(d), identity()));
+    }
+
+    private static DiffFormatter createDiffFormatter(
+        DiffOptions diffOptions, Repository repo, ObjectReader reader) {
+      try (DiffFormatter diffFormatter = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
+        diffFormatter.setReader(reader, repo.getConfig());
+        RawTextComparator cmp = comparatorFor(diffOptions.whitespace());
+        diffFormatter.setDiffComparator(cmp);
+        if (diffOptions.renameScore() != -1) {
+          diffFormatter.setDetectRenames(true);
+          diffFormatter.getRenameDetector().setRenameScore(diffOptions.renameScore());
+        }
+        diffFormatter.setDiffAlgorithm(DiffAlgorithmFactory.create(diffOptions.diffAlgorithm()));
+        return diffFormatter;
+      }
+    }
+
+    private static RawTextComparator comparatorFor(Whitespace ws) {
+      switch (ws) {
+        case IGNORE_ALL:
+          return RawTextComparator.WS_IGNORE_ALL;
+
+        case IGNORE_TRAILING:
+          return RawTextComparator.WS_IGNORE_TRAILING;
+
+        case IGNORE_LEADING_AND_TRAILING:
+          return RawTextComparator.WS_IGNORE_CHANGE;
+
+        case IGNORE_NONE:
+        default:
+          return RawTextComparator.DEFAULT;
+      }
+    }
+  }
+
+  /** An entity representing the options affecting the diff computation. */
+  @AutoValue
+  abstract static class DiffOptions {
+    /** Convert a {@link GitFileDiffCacheKey} input to a {@link DiffOptions}. */
+    static DiffOptions fromKey(GitFileDiffCacheKey key) {
+      return create(
+          key.oldTree(), key.newTree(), key.renameScore(), key.whitespace(), key.diffAlgorithm());
+    }
+
+    private static DiffOptions create(
+        ObjectId oldTree,
+        ObjectId newTree,
+        Integer renameScore,
+        Whitespace whitespace,
+        DiffAlgorithm diffAlgorithm) {
+      return new AutoValue_GitFileDiffCacheImpl_DiffOptions(
+          oldTree, newTree, renameScore, whitespace, diffAlgorithm);
+    }
+
+    abstract ObjectId oldTree();
+
+    abstract ObjectId newTree();
+
+    abstract Integer renameScore();
+
+    abstract Whitespace whitespace();
+
+    abstract DiffAlgorithm diffAlgorithm();
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java
new file mode 100644
index 0000000..f104388
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java
@@ -0,0 +1,127 @@
+// 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.gitfilediff;
+
+import static com.google.gerrit.server.patch.DiffUtil.stringSize;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache.GitFileDiffKeyProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl.DiffAlgorithm;
+import org.eclipse.jgit.lib.ObjectId;
+
+@AutoValue
+public abstract class GitFileDiffCacheKey {
+
+  /** A specific git project / repository. */
+  public abstract Project.NameKey project();
+
+  /** The old 20 bytes SHA-1 git tree ID used in the git tree diff */
+  public abstract ObjectId oldTree();
+
+  /** The new 20 bytes SHA-1 git tree ID used in the git tree diff */
+  public abstract ObjectId newTree();
+
+  /** File name in the tree identified by {@link #newTree()} */
+  public abstract String newFilePath();
+
+  /**
+   * Percentage score used to identify a file as a "rename". A special value of -1 means that the
+   * computation will ignore renames and rename detection will be disabled.
+   */
+  public abstract int renameScore();
+
+  public abstract DiffAlgorithm diffAlgorithm();
+
+  public abstract DiffPreferencesInfo.Whitespace whitespace();
+
+  public int weight() {
+    return stringSize(project().get())
+        + 20 * 2 // oldTree and newTree
+        + stringSize(newFilePath())
+        + 4 // renameScore
+        + 4 // diffAlgorithm
+        + 4; // whitespace
+  }
+
+  public static Builder builder() {
+    return new AutoValue_GitFileDiffCacheKey.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder project(NameKey value);
+
+    public abstract Builder oldTree(ObjectId value);
+
+    public abstract Builder newTree(ObjectId value);
+
+    public abstract Builder newFilePath(String value);
+
+    public abstract Builder renameScore(Integer value);
+
+    public Builder disableRenameDetection() {
+      renameScore(-1);
+      return this;
+    }
+
+    public abstract Builder diffAlgorithm(DiffAlgorithm value);
+
+    public abstract Builder whitespace(Whitespace value);
+
+    public abstract GitFileDiffCacheKey build();
+  }
+
+  public enum Serializer implements CacheSerializer<GitFileDiffCacheKey> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(GitFileDiffCacheKey key) {
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      return Protos.toByteArray(
+          GitFileDiffKeyProto.newBuilder()
+              .setProject(key.project().get())
+              .setATree(idConverter.toByteString(key.oldTree()))
+              .setBTree(idConverter.toByteString(key.newTree()))
+              .setFilePath(key.newFilePath())
+              .setRenameScore(key.renameScore())
+              .setDiffAlgorithm(key.diffAlgorithm().name())
+              .setWhitepsace(key.whitespace().name())
+              .build());
+    }
+
+    @Override
+    public GitFileDiffCacheKey deserialize(byte[] in) {
+      GitFileDiffKeyProto proto = Protos.parseUnchecked(GitFileDiffKeyProto.parser(), in);
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      return GitFileDiffCacheKey.builder()
+          .project(Project.nameKey(proto.getProject()))
+          .oldTree(idConverter.fromByteString(proto.getATree()))
+          .newTree(idConverter.fromByteString(proto.getBTree()))
+          .newFilePath(proto.getFilePath())
+          .renameScore(proto.getRenameScore())
+          .diffAlgorithm(DiffAlgorithm.valueOf(proto.getDiffAlgorithm()))
+          .whitespace(Whitespace.valueOf(proto.getWhitepsace()))
+          .build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffWeigher.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffWeigher.java
new file mode 100644
index 0000000..47f7791
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffWeigher.java
@@ -0,0 +1,25 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.patch.gitfilediff;
+
+import com.google.common.cache.Weigher;
+
+public class GitFileDiffWeigher implements Weigher<GitFileDiffCacheKey, GitFileDiff> {
+
+  @Override
+  public int weigh(GitFileDiffCacheKey key, GitFileDiff gitFileDiff) {
+    return key.weight() + gitFileDiff.weight();
+  }
+}
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
index cf6a184..66299a8 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
@@ -49,8 +49,6 @@
 public class DefaultPermissionBackend extends PermissionBackend {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final CurrentUser.PropertyKey<Boolean> IS_ADMIN = CurrentUser.PropertyKey.create();
-
   private final Provider<CurrentUser> currentUser;
   private final ProjectCache projectCache;
   private final ProjectControl.Factory projectControlFactory;
@@ -84,8 +82,15 @@
 
   @Override
   public WithUser absentUser(Account.Id id) {
-    IdentifiedUser identifiedUser = identifiedUserFactory.create(requireNonNull(id, "user"));
-    return new WithUserImpl(identifiedUser);
+    requireNonNull(id, "user");
+    Optional<Account.Id> user = getAccountIdOfIdentifiedUser();
+    if (user.isPresent() && id.equals(user.get())) {
+      // What looked liked an absent user is actually the current caller. Use the per-request
+      // singleton IdentifiedUser instead of constructing a new object to leverage caching in member
+      // variables of IdentifiedUser.
+      return new WithUserImpl(currentUser.get().asIdentifiedUser());
+    }
+    return new WithUserImpl(identifiedUserFactory.create(requireNonNull(id, "user")));
   }
 
   @Override
@@ -93,6 +98,21 @@
     return true;
   }
 
+  /**
+   * Returns the {@link com.google.gerrit.entities.Account.Id} of the current user if a user is
+   * signed in. Catches exceptions so that background jobs don't get impacted.
+   */
+  private Optional<Account.Id> getAccountIdOfIdentifiedUser() {
+    try {
+      return currentUser.get().isIdentifiedUser()
+          ? Optional.of(currentUser.get().getAccountId())
+          : Optional.empty();
+    } catch (Exception e) {
+      logger.atFine().withCause(e).log("Unable to get current user");
+      return Optional.empty();
+    }
+  }
+
   class WithUserImpl extends WithUser {
     private final CurrentUser user;
     private Boolean admin;
@@ -202,21 +222,13 @@
     }
 
     private Boolean computeAdmin() {
-      Optional<Boolean> r = user.get(IS_ADMIN);
-      if (r.isPresent()) {
-        return r.get();
-      }
-
-      boolean isAdmin;
       if (user.isImpersonating()) {
-        isAdmin = false;
-      } else if (user instanceof PeerDaemonUser) {
-        isAdmin = true;
-      } else {
-        isAdmin = allow(capabilities().administrateServer);
+        return false;
       }
-      user.put(IS_ADMIN, isAdmin);
-      return isAdmin;
+      if (user instanceof PeerDaemonUser) {
+        return true;
+      }
+      return allow(capabilities().administrateServer);
     }
 
     private boolean canEmailReviewers() {
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
index 3f84dff..d2e85be 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
@@ -31,6 +31,7 @@
       // TODO(hiesel) Hide ProjectControl, RefControl, ChangeControl related bindings.
       factory(ProjectControl.Factory.class);
       factory(DefaultRefFilter.Factory.class);
+      factory(VisibleChangesCache.Factory.class);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
index eca30b6..5092e12 100644
--- a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
+++ b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.permissions;
 
 import static com.google.common.base.Preconditions.checkState;
-import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.flogger.LazyArgs.lazy;
 import static com.google.gerrit.entities.RefNames.REFS_CONFIG;
 import static java.util.stream.Collectors.toCollection;
 
@@ -23,12 +23,8 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Maps;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -43,19 +39,15 @@
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeNotes.Factory.ChangeNotesResult;
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
 import java.util.List;
-import java.util.Map;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.Ref;
@@ -70,7 +62,6 @@
 
   private final TagCache tagCache;
   private final ChangeNotes.Factory changeNotesFactory;
-  @Nullable private final SearchingChangeCacheImpl changeCache;
   private final PermissionBackend permissionBackend;
   private final RefVisibilityControl refVisibilityControl;
   private final ProjectControl projectControl;
@@ -80,27 +71,28 @@
   private final Counter0 fullFilterCount;
   private final Counter0 skipFilterCount;
   private final boolean skipFullRefEvaluationIfAllRefsAreVisible;
+  private final VisibleChangesCache.Factory visibleChangesCacheFactory;
 
-  private Map<Change.Id, BranchNameKey> visibleChanges;
+  private VisibleChangesCache visibleChangesCache;
 
   @Inject
   DefaultRefFilter(
       TagCache tagCache,
       ChangeNotes.Factory changeNotesFactory,
-      @Nullable SearchingChangeCacheImpl changeCache,
       PermissionBackend permissionBackend,
       RefVisibilityControl refVisibilityControl,
       @GerritServerConfig Config config,
       MetricMaker metricMaker,
+      VisibleChangesCache.Factory visibleChangesCacheFactory,
       @Assisted ProjectControl projectControl) {
     this.tagCache = tagCache;
     this.changeNotesFactory = changeNotesFactory;
-    this.changeCache = changeCache;
     this.permissionBackend = permissionBackend;
     this.refVisibilityControl = refVisibilityControl;
     this.skipFullRefEvaluationIfAllRefsAreVisible =
         config.getBoolean("auth", "skipFullRefEvaluationIfAllRefsAreVisible", true);
     this.projectControl = projectControl;
+    this.visibleChangesCacheFactory = visibleChangesCacheFactory;
 
     this.user = projectControl.getUser();
     this.projectState = projectControl.getProjectState();
@@ -122,11 +114,12 @@
   /** Filters given refs and tags by visibility. */
   Collection<Ref> filter(Collection<Ref> refs, Repository repo, RefFilterOptions opts)
       throws PermissionBackendException {
+    visibleChangesCache = visibleChangesCacheFactory.create(projectControl, repo);
     logger.atFinest().log(
         "Filter refs for repository %s by visibility (options = %s, refs = %s)",
         projectState.getNameKey(), opts, refs);
     logger.atFinest().log("Calling user: %s", user.getLoggableName());
-    logger.atFinest().log("Groups: %s", user.getEffectiveGroups().getKnownGroups());
+    logger.atFinest().log("Groups: %s", lazy(() -> user.getEffectiveGroups().getKnownGroups()));
     logger.atFinest().log(
         "auth.skipFullRefEvaluationIfAllRefsAreVisible = %s",
         skipFullRefEvaluationIfAllRefsAreVisible);
@@ -255,12 +248,13 @@
       } else if ((changeId = Change.Id.fromRef(refName)) != null) {
         // This is a mere performance optimization. RefVisibilityControl could determine the
         // visibility of these refs just fine. But instead, we use highly-optimized logic that
-        // looks only on the last 10k most recent changes using the change index and a cache.
+        // looks only on the available changes in the change index and cache (which are the
+        // most recent changes).
         if (hasAccessDatabase) {
           resultRefs.add(ref);
-        } else if (!visible(repo, changeId)) {
+        } else if (!visibleChangesCache.isVisible(changeId)) {
           logger.atFinest().log("Filter out invisible change ref %s", refName);
-        } else if (RefNames.isRefsEdit(refName) && !visibleEdit(repo, refName)) {
+        } else if (RefNames.isRefsEdit(refName) && !visibleEdit(refName)) {
           logger.atFinest().log("Filter out invisible change edit ref %s", refName);
         } else {
           // Change is visible
@@ -292,10 +286,7 @@
                       && !r.getName().startsWith(RefNames.REFS_TAGS)
                       && !r.isSymbolic()
                       && !r.getName().equals(RefNames.REFS_CONFIG))
-          // Don't use the default Java Collections.toList() as that is not size-aware and would
-          // expand an array list as new elements are added. Instead, provide a list that has the
-          // right size. This spares incremental list expansion which is quadratic in complexity.
-          .collect(toCollection(() -> new ArrayList<>(allRefs.size())));
+          .collect(Collectors.toList());
     } catch (IOException e) {
       throw new PermissionBackendException(e);
     }
@@ -305,27 +296,12 @@
     if (!canReadRef(REFS_CONFIG)) {
       return refs.stream()
           .filter(r -> !r.getName().equals(REFS_CONFIG))
-          // Don't use the default Java Collections.toList() as that is not size-aware and would
-          // expand an array list as new elements are added. Instead, provide a list that has the
-          // right size. This spares incremental list expansion which is quadratic in complexity.
           .collect(toCollection(() -> new ArrayList<>(refs.size())));
     }
     return refs;
   }
 
-  private boolean visible(Repository repo, Change.Id changeId) throws PermissionBackendException {
-    if (visibleChanges == null) {
-      if (changeCache == null) {
-        visibleChanges = visibleChangesByScan(repo);
-      } else {
-        visibleChanges = visibleChangesBySearch();
-      }
-      logger.atFinest().log("Visible changes: %s", visibleChanges.keySet());
-    }
-    return visibleChanges.containsKey(changeId);
-  }
-
-  private boolean visibleEdit(Repository repo, String name) throws PermissionBackendException {
+  private boolean visibleEdit(String name) throws PermissionBackendException {
     Change.Id id = Change.Id.fromEditRefPart(name);
     if (id == null) {
       logger.atWarning().log("Couldn't extract change ID from edit ref %s", name);
@@ -334,20 +310,16 @@
 
     if (user.isIdentifiedUser()
         && name.startsWith(RefNames.refsEditPrefix(user.asIdentifiedUser().getAccountId()))
-        && visible(repo, id)) {
+        && visibleChangesCache.isVisible(id)) {
       logger.atFinest().log("Own change edit ref is visible: %s", name);
       return true;
     }
 
-    // Initialize visibleChanges if it wasn't initialized yet.
-    if (visibleChanges == null) {
-      visible(repo, id);
-    }
-    if (visibleChanges.containsKey(id)) {
+    if (visibleChangesCache.isVisible(id)) {
       try {
         // Default to READ_PRIVATE_CHANGES as there is no special permission for reading edits.
         permissionBackendForProject
-            .ref(visibleChanges.get(id).branch())
+            .ref(visibleChangesCache.getBranchNameKey(id).branch())
             .check(RefPermission.READ_PRIVATE_CHANGES);
         logger.atFinest().log("Foreign change edit ref is visible: %s", name);
         return true;
@@ -361,72 +333,6 @@
     return false;
   }
 
-  private Map<Change.Id, BranchNameKey> visibleChangesBySearch() throws PermissionBackendException {
-    Project.NameKey project = projectState.getNameKey();
-    try {
-      Map<Change.Id, BranchNameKey> visibleChanges = new HashMap<>();
-      for (ChangeData cd : changeCache.getChangeData(project)) {
-        if (!projectState.statePermitsRead()) {
-          continue;
-        }
-        try {
-          permissionBackendForProject.change(cd).check(ChangePermission.READ);
-          visibleChanges.put(cd.getId(), cd.change().getDest());
-        } catch (AuthException e) {
-          // Do nothing.
-        }
-      }
-      return visibleChanges;
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log(
-          "Cannot load changes for project %s, assuming no changes are visible", project);
-      return Collections.emptyMap();
-    }
-  }
-
-  private Map<Change.Id, BranchNameKey> visibleChangesByScan(Repository repo)
-      throws PermissionBackendException {
-    Project.NameKey p = projectState.getNameKey();
-    ImmutableList<ChangeNotesResult> changes;
-    try {
-      changes = changeNotesFactory.scan(repo, p).collect(toImmutableList());
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log(
-          "Cannot load changes for project %s, assuming no changes are visible", p);
-      return Collections.emptyMap();
-    }
-
-    Map<Change.Id, BranchNameKey> result = Maps.newHashMapWithExpectedSize(changes.size());
-    for (ChangeNotesResult notesResult : changes) {
-      ChangeNotes notes = toNotes(notesResult);
-      if (notes != null) {
-        result.put(notes.getChangeId(), notes.getChange().getDest());
-      }
-    }
-    return result;
-  }
-
-  @Nullable
-  private ChangeNotes toNotes(ChangeNotesResult r) throws PermissionBackendException {
-    if (r.error().isPresent()) {
-      logger.atWarning().withCause(r.error().get()).log(
-          "Failed to load change %s in %s", r.id(), projectState.getName());
-      return null;
-    }
-
-    if (!projectState.statePermitsRead()) {
-      return null;
-    }
-
-    try {
-      permissionBackendForProject.change(r.notes()).check(ChangePermission.READ);
-      return r.notes();
-    } catch (AuthException e) {
-      // Skip.
-    }
-    return null;
-  }
-
   private boolean isMetadata(String name) {
     boolean isMetaData = RefNames.isRefsChanges(name) || RefNames.isRefsEdit(name);
     logger.atFinest().log("ref %s is " + (isMetaData ? "" : "not ") + "a metadata ref", name);
diff --git a/java/com/google/gerrit/server/permissions/GlobalPermission.java b/java/com/google/gerrit/server/permissions/GlobalPermission.java
index 07c9e84..d4f22e6 100644
--- a/java/com/google/gerrit/server/permissions/GlobalPermission.java
+++ b/java/com/google/gerrit/server/permissions/GlobalPermission.java
@@ -18,6 +18,7 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.CapabilityScope;
 import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
@@ -31,7 +32,12 @@
 import java.util.Optional;
 import java.util.Set;
 
-/** Global server permissions built into Gerrit. */
+/**
+ * Global server permissions built into Gerrit.
+ *
+ * <p>See also {@link GlobalCapability} which lists the equivalent strings used in the
+ * refs/meta/config settings in All-Projects.
+ */
 public enum GlobalPermission implements GlobalOrPluginPermission {
   ACCESS_DATABASE,
   ADMINISTRATE_SERVER,
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackend.java b/java/com/google/gerrit/server/permissions/PermissionBackend.java
index eceb970..27c6793 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -60,9 +60,9 @@
  * <p>{@code PermissionBackend} is a singleton for the server, acting as a factory for lightweight
  * request instances. Implementation classes may cache supporting data inside of {@link WithUser},
  * {@link ForProject}, {@link ForRef}, and {@link ForChange} instances, in addition to storing
- * within {@link CurrentUser} using a {@link com.google.gerrit.server.CurrentUser.PropertyKey}.
- * {@link GlobalPermission} caching for {@link WithUser} may best cached inside {@link CurrentUser}
- * as {@link WithUser} instances are frequently created.
+ * within {@link CurrentUser} using a {@link com.google.gerrit.server.PropertyMap.Key}. {@link
+ * GlobalPermission} caching for {@link WithUser} may best cached inside {@link CurrentUser} as
+ * {@link WithUser} instances are frequently created.
  *
  * <p>Example use:
  *
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index fd82559..dd00dca 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.logging.CallerFinder;
+import com.google.gerrit.server.logging.LoggingContext;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
 import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
@@ -425,40 +426,52 @@
   /** True if the user has this permission. */
   private boolean canPerform(String permissionName, boolean isChangeOwner, boolean withForce) {
     if (isBlocked(permissionName, isChangeOwner, withForce)) {
-      logger.atFine().log(
-          "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s'"
-              + " because this permission is blocked (caller: %s)",
-          getUser().getLoggableName(),
-          permissionName,
-          withForce,
-          projectControl.getProject().getName(),
-          refName,
-          callerFinder.findCallerLazy());
+      if (logger.atFine().isEnabled() || LoggingContext.getInstance().isAclLogging()) {
+        String logMessage =
+            String.format(
+                "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s'"
+                    + " because this permission is blocked",
+                getUser().getLoggableName(),
+                permissionName,
+                withForce,
+                projectControl.getProject().getName(),
+                refName);
+        LoggingContext.getInstance().addAclLogRecord(logMessage);
+        logger.atFine().log("%s (caller: %s)", logMessage, callerFinder.findCallerLazy());
+      }
       return false;
     }
 
     for (PermissionRule pr : relevant.getAllowRules(permissionName)) {
       if (isAllow(pr, withForce) && projectControl.match(pr, isChangeOwner)) {
-        logger.atFine().log(
-            "'%s' can perform '%s' with force=%s on project '%s' for ref '%s' (caller: %s)",
-            getUser().getLoggableName(),
-            permissionName,
-            withForce,
-            projectControl.getProject().getName(),
-            refName,
-            callerFinder.findCallerLazy());
+        if (logger.atFine().isEnabled() || LoggingContext.getInstance().isAclLogging()) {
+          String logMessage =
+              String.format(
+                  "'%s' can perform '%s' with force=%s on project '%s' for ref '%s'",
+                  getUser().getLoggableName(),
+                  permissionName,
+                  withForce,
+                  projectControl.getProject().getName(),
+                  refName);
+          LoggingContext.getInstance().addAclLogRecord(logMessage);
+          logger.atFine().log("%s (caller: %s)", logMessage, callerFinder.findCallerLazy());
+        }
         return true;
       }
     }
 
-    logger.atFine().log(
-        "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s' (caller: %s)",
-        getUser().getLoggableName(),
-        permissionName,
-        withForce,
-        projectControl.getProject().getName(),
-        refName,
-        callerFinder.findCallerLazy());
+    if (logger.atFine().isEnabled() || LoggingContext.getInstance().isAclLogging()) {
+      String logMessage =
+          String.format(
+              "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s'",
+              getUser().getLoggableName(),
+              permissionName,
+              withForce,
+              projectControl.getProject().getName(),
+              refName);
+      LoggingContext.getInstance().addAclLogRecord(logMessage);
+      logger.atFine().log("%s (caller: %s)", logMessage, callerFinder.findCallerLazy());
+    }
     return false;
   }
 
diff --git a/java/com/google/gerrit/server/permissions/RefVisibilityControl.java b/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
index 4744037..cc6387b 100644
--- a/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
+++ b/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
@@ -118,9 +118,14 @@
     Account.Id accountId;
     if ((accountId = Account.Id.fromRef(refName)) != null) {
       // Account ref is visible only to the corresponding account.
-      if (accountId.equals(currentUserAccountId)
-          && projectControl.controlForRef(refName).hasReadPermissionOnRef(true)) {
-        return true;
+      if (accountId.equals(currentUserAccountId)) {
+        // Always allow visibility to refs/draft-comments and refs/starred-changes. For all other
+        // refs, check if the user has read permissions.
+        if (RefNames.isRefsDraftsComments(refName)
+            || RefNames.isRefsStarredChanges(refName)
+            || projectControl.controlForRef(refName).hasReadPermissionOnRef(true)) {
+          return true;
+        }
       }
       return false;
     }
diff --git a/java/com/google/gerrit/server/permissions/VisibleChangesCache.java b/java/com/google/gerrit/server/permissions/VisibleChangesCache.java
new file mode 100644
index 0000000..2e47576
--- /dev/null
+++ b/java/com/google/gerrit/server/permissions/VisibleChangesCache.java
@@ -0,0 +1,169 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.permissions;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.git.SearchingChangeCacheImpl;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeNotes.Factory.ChangeNotesResult;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * Gets all of the visible by current user changes in the repository that are available in the
+ * change index and cache.
+ */
+class VisibleChangesCache {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  interface Factory {
+    VisibleChangesCache create(ProjectControl projectControl, Repository repository);
+  }
+
+  @Nullable private final SearchingChangeCacheImpl changeCache;
+  private final ProjectState projectState;
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final PermissionBackend.ForProject permissionBackendForProject;
+
+  private final Repository repository;
+  private Map<Change.Id, BranchNameKey> visibleChanges;
+
+  @Inject
+  VisibleChangesCache(
+      @Nullable SearchingChangeCacheImpl changeCache,
+      PermissionBackend permissionBackend,
+      ChangeNotes.Factory changeNotesFactory,
+      @Assisted ProjectControl projectControl,
+      @Assisted Repository repository) {
+    this.changeCache = changeCache;
+    this.projectState = projectControl.getProjectState();
+    this.permissionBackendForProject =
+        permissionBackend.user(projectControl.getUser()).project(projectState.getNameKey());
+    this.changeNotesFactory = changeNotesFactory;
+    this.repository = repository;
+  }
+
+  /**
+   * Returns {@code true} if the {@code changeId} in repository {@code repo} is visible to the user,
+   * by looking at the cached visible changes.
+   */
+  public boolean isVisible(Change.Id changeId) throws PermissionBackendException {
+    cachedVisibleChanges();
+    return visibleChanges.containsKey(changeId);
+  }
+
+  /**
+   * Returns the visible changes in the repository {@code repo}. If not cached, computes the visible
+   * changes and caches them.
+   */
+  public Map<Change.Id, BranchNameKey> cachedVisibleChanges() throws PermissionBackendException {
+    if (visibleChanges == null) {
+      if (changeCache == null) {
+        visibleChangesByScan();
+      } else {
+        visibleChangesBySearch();
+      }
+      logger.atFinest().log("Visible changes: %s", visibleChanges.keySet());
+    }
+    return visibleChanges;
+  }
+
+  /**
+   * Returns the {@code BranchNameKey} for {@code changeId}. If not cached, computes *all* visible
+   * changes and caches them before returning this specific change. If not visible or not found,
+   * returns {@code null}.
+   */
+  @Nullable
+  public BranchNameKey getBranchNameKey(Change.Id changeId) throws PermissionBackendException {
+    return cachedVisibleChanges().get(changeId);
+  }
+
+  private void visibleChangesBySearch() throws PermissionBackendException {
+    visibleChanges = new HashMap<>();
+    Project.NameKey project = projectState.getNameKey();
+    try {
+      for (ChangeData cd : changeCache.getChangeData(project)) {
+        if (!projectState.statePermitsRead()) {
+          continue;
+        }
+        try {
+          permissionBackendForProject.change(cd).check(ChangePermission.READ);
+          visibleChanges.put(cd.getId(), cd.change().getDest());
+        } catch (AuthException e) {
+          // Do nothing.
+        }
+      }
+    } catch (StorageException e) {
+      logger.atSevere().withCause(e).log(
+          "Cannot load changes for project %s, assuming no changes are visible", project);
+    }
+  }
+
+  private void visibleChangesByScan() throws PermissionBackendException {
+    visibleChanges = new HashMap<>();
+    Project.NameKey p = projectState.getNameKey();
+    ImmutableList<ChangeNotesResult> changes;
+    try {
+      changes = changeNotesFactory.scan(repository, p).collect(toImmutableList());
+    } catch (IOException e) {
+      logger.atSevere().withCause(e).log(
+          "Cannot load changes for project %s, assuming no changes are visible", p);
+      return;
+    }
+
+    for (ChangeNotesResult notesResult : changes) {
+      ChangeNotes notes = toNotes(notesResult);
+      if (notes != null) {
+        visibleChanges.put(notes.getChangeId(), notes.getChange().getDest());
+      }
+    }
+  }
+
+  @Nullable
+  private ChangeNotes toNotes(ChangeNotesResult r) throws PermissionBackendException {
+    if (r.error().isPresent()) {
+      logger.atWarning().withCause(r.error().get()).log(
+          "Failed to load change %s in %s", r.id(), projectState.getName());
+      return null;
+    }
+
+    if (!projectState.statePermitsRead()) {
+      return null;
+    }
+
+    try {
+      permissionBackendForProject.change(r.notes()).check(ChangePermission.READ);
+      return r.notes();
+    } catch (AuthException e) {
+      // Skip.
+    }
+    return null;
+  }
+}
diff --git a/java/com/google/gerrit/server/project/LabelDefinitionJson.java b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
index 7aa4029..730162f 100644
--- a/java/com/google/gerrit/server/project/LabelDefinitionJson.java
+++ b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
@@ -35,6 +35,8 @@
     label.copyAnyScore = toBoolean(labelType.isCopyAnyScore());
     label.copyMinScore = toBoolean(labelType.isCopyMinScore());
     label.copyMaxScore = toBoolean(labelType.isCopyMaxScore());
+    label.copyAllScoresIfListOfFilesDidNotChange =
+        toBoolean(labelType.isCopyAllScoresIfListOfFilesDidNotChange());
     label.copyAllScoresIfNoChange = toBoolean(labelType.isCopyAllScoresIfNoChange());
     label.copyAllScoresIfNoCodeChange = toBoolean(labelType.isCopyAllScoresIfNoCodeChange());
     label.copyAllScoresOnTrivialRebase = toBoolean(labelType.isCopyAllScoresOnTrivialRebase());
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 89038e2..4a063a3 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -111,6 +111,8 @@
   public static final String KEY_IGNORE_SELF_APPROVAL = "ignoreSelfApproval";
   public static final String KEY_COPY_ANY_SCORE = "copyAnyScore";
   public static final String KEY_COPY_MAX_SCORE = "copyMaxScore";
+  public static final String KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE =
+      "copyAllScoresIfListOfFilesDidNotChange";
   public static final String KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE =
       "copyAllScoresOnMergeFirstParentUpdate";
   public static final String KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = "copyAllScoresOnTrivialRebase";
@@ -385,6 +387,7 @@
     this.accountsSection = accountsSection;
   }
 
+  /** Returns an access section, {@code name} typically is a ref pattern. */
   public AccessSection getAccessSection(String name) {
     return accessSections.get(name);
   }
@@ -1028,6 +1031,12 @@
           rc.getBoolean(LABEL, name, KEY_COPY_MIN_SCORE, LabelType.DEF_COPY_MIN_SCORE));
       label.setCopyMaxScore(
           rc.getBoolean(LABEL, name, KEY_COPY_MAX_SCORE, LabelType.DEF_COPY_MAX_SCORE));
+      label.setCopyAllScoresIfListOfFilesDidNotChange(
+          rc.getBoolean(
+              LABEL,
+              name,
+              KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+              LabelType.DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE));
       label.setCopyAllScoresOnMergeFirstParentUpdate(
           rc.getBoolean(
               LABEL,
@@ -1565,6 +1574,13 @@
           rc,
           LABEL,
           name,
+          KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+          label.isCopyAllScoresIfListOfFilesDidNotChange(),
+          LabelType.DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE);
+      setBooleanConfigKey(
+          rc,
+          LABEL,
+          name,
           KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
           label.isCopyAllScoresOnMergeFirstParentUpdate(),
           LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
diff --git a/java/com/google/gerrit/server/project/ProjectLevelConfig.java b/java/com/google/gerrit/server/project/ProjectLevelConfig.java
index 4825233..555cf4c 100644
--- a/java/com/google/gerrit/server/project/ProjectLevelConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectLevelConfig.java
@@ -16,14 +16,15 @@
 
 import static java.util.stream.Collectors.toList;
 
-import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
+import com.google.gerrit.entities.ImmutableConfig;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import java.io.IOException;
 import java.util.Arrays;
 import java.util.Set;
 import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
 import org.eclipse.jgit.annotations.Nullable;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
@@ -73,19 +74,17 @@
 
   private final String fileName;
   private final ProjectState project;
-  private Config cfg;
+  private final ImmutableConfig immutableConfig;
 
-  public ProjectLevelConfig(String fileName, ProjectState project, Config cfg) {
+  public ProjectLevelConfig(
+      String fileName, ProjectState project, @Nullable ImmutableConfig immutableConfig) {
     this.fileName = fileName;
     this.project = project;
-    this.cfg = cfg;
+    this.immutableConfig = immutableConfig == null ? ImmutableConfig.EMPTY : immutableConfig;
   }
 
   public Config get() {
-    if (cfg == null) {
-      cfg = new Config();
-    }
-    return cfg;
+    return immutableConfig.mutableCopy();
   }
 
   public Config getWithInheritance() {
@@ -105,58 +104,54 @@
    * @return a combined config.
    */
   public Config getWithInheritance(boolean merge) {
-    Config cfgWithInheritance = new Config();
-    try {
-      cfgWithInheritance.fromText(get().toText());
-    } catch (ConfigInvalidException e) {
-      // cannot happen
-    }
-    ProjectState parent = Iterables.getFirst(project.parents(), null);
-    if (parent != null) {
-      Config parentCfg = parent.getConfig(fileName).getWithInheritance();
-      for (String section : parentCfg.getSections()) {
-        Set<String> allNames = get().getNames(section);
-        for (String name : parentCfg.getNames(section)) {
-          String[] parentValues = parentCfg.getStringList(section, null, name);
-          if (!allNames.contains(name)) {
-            cfgWithInheritance.setStringList(section, null, name, Arrays.asList(parentValues));
-          } else if (merge) {
-            cfgWithInheritance.setStringList(
-                section,
-                null,
-                name,
-                Stream.concat(
-                        Arrays.stream(cfg.getStringList(section, null, name)),
-                        Arrays.stream(parentValues))
-                    .sorted()
-                    .distinct()
-                    .collect(toList()));
-          }
-        }
+    Config cfg = new Config();
+    // Traverse from All-Projects down to the current project
+    StreamSupport.stream(project.treeInOrder().spliterator(), false)
+        .forEach(
+            parent -> {
+              ImmutableConfig levelCfg = parent.getConfig(fileName).immutableConfig;
+              for (String section : levelCfg.getSections()) {
+                Set<String> allNames = cfg.getNames(section);
+                for (String name : levelCfg.getNames(section)) {
+                  String[] levelValues = levelCfg.getStringList(section, null, name);
+                  if (allNames.contains(name) && merge) {
+                    cfg.setStringList(
+                        section,
+                        null,
+                        name,
+                        Stream.concat(
+                                Arrays.stream(cfg.getStringList(section, null, name)),
+                                Arrays.stream(levelValues))
+                            .sorted()
+                            .distinct()
+                            .collect(toList()));
+                  } else {
+                    cfg.setStringList(section, null, name, Arrays.asList(levelValues));
+                  }
+                }
 
-        for (String subsection : parentCfg.getSubsections(section)) {
-          allNames = get().getNames(section, subsection);
-          for (String name : parentCfg.getNames(section, subsection)) {
-            String[] parentValues = parentCfg.getStringList(section, subsection, name);
-            if (!allNames.contains(name)) {
-              cfgWithInheritance.setStringList(
-                  section, subsection, name, Arrays.asList(parentValues));
-            } else if (merge) {
-              cfgWithInheritance.setStringList(
-                  section,
-                  subsection,
-                  name,
-                  Streams.concat(
-                          Arrays.stream(cfg.getStringList(section, subsection, name)),
-                          Arrays.stream(parentValues))
-                      .sorted()
-                      .distinct()
-                      .collect(toList()));
-            }
-          }
-        }
-      }
-    }
-    return cfgWithInheritance;
+                for (String subsection : levelCfg.getSubsections(section)) {
+                  allNames = cfg.getNames(section, subsection);
+                  for (String name : levelCfg.getNames(section, subsection)) {
+                    String[] levelValues = levelCfg.getStringList(section, subsection, name);
+                    if (allNames.contains(name) && merge) {
+                      cfg.setStringList(
+                          section,
+                          subsection,
+                          name,
+                          Streams.concat(
+                                  Arrays.stream(cfg.getStringList(section, subsection, name)),
+                                  Arrays.stream(levelValues))
+                              .sorted()
+                              .distinct()
+                              .collect(toList()));
+                    } else {
+                      cfg.setStringList(section, subsection, name, Arrays.asList(levelValues));
+                    }
+                  }
+                }
+              }
+            });
+    return cfg;
   }
 }
diff --git a/java/com/google/gerrit/server/project/ProjectState.java b/java/com/google/gerrit/server/project/ProjectState.java
index eecf1fe..249eb35 100644
--- a/java/com/google/gerrit/server/project/ProjectState.java
+++ b/java/com/google/gerrit/server/project/ProjectState.java
@@ -14,7 +14,10 @@
 
 package com.google.gerrit.server.project;
 
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.entities.PermissionRule.Action.ALLOW;
+import static java.util.Comparator.comparing;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.FluentIterable;
@@ -174,8 +177,9 @@
   }
 
   public ProjectLevelConfig getConfig(String fileName) {
-    Optional<Config> rawConfig = cachedConfig.getProjectLevelConfig(fileName);
-    return new ProjectLevelConfig(fileName, this, rawConfig.orElse(new Config()));
+    checkState(fileName.endsWith(".config"), "file name must end in .config. is: " + fileName);
+    return new ProjectLevelConfig(
+        fileName, this, cachedConfig.getParsedProjectLevelConfigs().get(fileName));
   }
 
   public long getMaxObjectSizeLimit() {
@@ -264,7 +268,10 @@
   List<SectionMatcher> getLocalAccessSections() {
     List<SectionMatcher> sm = localAccessSections;
     if (sm == null) {
-      Collection<AccessSection> fromConfig = cachedConfig.getAccessSections().values();
+      ImmutableList<AccessSection> fromConfig =
+          cachedConfig.getAccessSections().values().stream()
+              .sorted(comparing(AccessSection::getName))
+              .collect(toImmutableList());
       sm = new ArrayList<>(fromConfig.size());
       for (AccessSection section : fromConfig) {
         if (isAllProjects) {
diff --git a/java/com/google/gerrit/server/project/RefUtil.java b/java/com/google/gerrit/server/project/RefUtil.java
index dc8cdc7..797756b 100644
--- a/java/com/google/gerrit/server/project/RefUtil.java
+++ b/java/com/google/gerrit/server/project/RefUtil.java
@@ -102,6 +102,7 @@
     return Constants.R_HEADS;
   }
 
+  /** Fully qualifies a tag name to refs/tags/TAG-NAME */
   public static String normalizeTagRef(String tag) throws BadRequestException {
     String result = tag;
     while (result.startsWith("/")) {
diff --git a/java/com/google/gerrit/server/project/RemoveReviewerControl.java b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
index 6bf3beb..652c49f 100644
--- a/java/com/google/gerrit/server/project/RemoveReviewerControl.java
+++ b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
@@ -91,7 +91,7 @@
       Account.Id reviewer,
       int value)
       throws PermissionBackendException {
-    if (change.isMerged()) {
+    if (change.isMerged() && value != 0) {
       return false;
     }
 
diff --git a/java/com/google/gerrit/server/project/testing/BUILD b/java/com/google/gerrit/server/project/testing/BUILD
index 3112b5a..ab75ec7 100644
--- a/java/com/google/gerrit/server/project/testing/BUILD
+++ b/java/com/google/gerrit/server/project/testing/BUILD
@@ -5,8 +5,5 @@
     testonly = True,
     srcs = glob(["*.java"]),
     visibility = ["//visibility:public"],
-    deps = [
-        "//java/com/google/gerrit/common:server",
-        "//java/com/google/gerrit/entities",
-    ],
+    deps = ["//java/com/google/gerrit/entities"],
 )
diff --git a/java/com/google/gerrit/server/project/testing/TestLabels.java b/java/com/google/gerrit/server/project/testing/TestLabels.java
index 157c746..62f8560 100644
--- a/java/com/google/gerrit/server/project/testing/TestLabels.java
+++ b/java/com/google/gerrit/server/project/testing/TestLabels.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.project.testing;
 
 import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelValue;
 import java.util.Arrays;
@@ -22,7 +23,7 @@
 public class TestLabels {
   public static LabelType codeReview() {
     return label(
-        "Code-Review",
+        LabelId.CODE_REVIEW,
         value(2, "Looks good to me, approved"),
         value(1, "Looks good to me, but someone else must approve"),
         value(0, "No score"),
@@ -31,7 +32,8 @@
   }
 
   public static LabelType verified() {
-    return label("Verified", value(1, "Verified"), value(0, "No score"), value(-1, "Fails"));
+    return label(
+        LabelId.VERIFIED, value(1, LabelId.VERIFIED), value(0, "No score"), value(-1, "Fails"));
   }
 
   public static LabelType patchSetLock() {
diff --git a/java/com/google/gerrit/server/query/change/AfterPredicate.java b/java/com/google/gerrit/server/query/change/AfterPredicate.java
index df5a71d..8f92d9a 100644
--- a/java/com/google/gerrit/server/query/change/AfterPredicate.java
+++ b/java/com/google/gerrit/server/query/change/AfterPredicate.java
@@ -14,15 +14,21 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.index.change.ChangeField;
+import java.sql.Timestamp;
 import java.util.Date;
 
+/**
+ * Predicate that matches a {@link Timestamp} field from the index in a range from the passed {@code
+ * String} representation of the Timestamp value to the maximum supported time.
+ */
 public class AfterPredicate extends TimestampRangeChangePredicate {
   protected final Date cut;
 
-  public AfterPredicate(String value) throws QueryParseException {
-    super(ChangeField.UPDATED, ChangeQueryBuilder.FIELD_BEFORE, value);
+  public AfterPredicate(FieldDef<ChangeData, Timestamp> def, String name, String value)
+      throws QueryParseException {
+    super(def, name, value);
     cut = parse(value);
   }
 
@@ -38,6 +44,10 @@
 
   @Override
   public boolean match(ChangeData cd) {
-    return cd.change().getLastUpdatedOn().getTime() >= cut.getTime();
+    Timestamp valueTimestamp = this.getValueTimestamp(cd);
+    if (valueTimestamp == null) {
+      return false;
+    }
+    return valueTimestamp.getTime() >= cut.getTime();
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/AgePredicate.java b/java/com/google/gerrit/server/query/change/AgePredicate.java
index 36eb5b7..d38789f 100644
--- a/java/com/google/gerrit/server/query/change/AgePredicate.java
+++ b/java/com/google/gerrit/server/query/change/AgePredicate.java
@@ -17,7 +17,6 @@
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.SECONDS;
 
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -46,7 +45,10 @@
 
   @Override
   public boolean match(ChangeData object) {
-    Change change = object.change();
-    return change != null && change.getLastUpdatedOn().getTime() <= cut;
+    Timestamp valueTimestamp = this.getValueTimestamp(object);
+    if (valueTimestamp == null) {
+      return false;
+    }
+    return valueTimestamp.getTime() <= cut;
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/BeforePredicate.java b/java/com/google/gerrit/server/query/change/BeforePredicate.java
index dacabc0..6e28ce6 100644
--- a/java/com/google/gerrit/server/query/change/BeforePredicate.java
+++ b/java/com/google/gerrit/server/query/change/BeforePredicate.java
@@ -14,15 +14,21 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.index.change.ChangeField;
+import java.sql.Timestamp;
 import java.util.Date;
 
+/**
+ * Predicate that matches a {@link Timestamp} field from the index in a range from the the epoch to
+ * the passed {@code String} representation of the Timestamp value.
+ */
 public class BeforePredicate extends TimestampRangeChangePredicate {
   protected final Date cut;
 
-  public BeforePredicate(String value) throws QueryParseException {
-    super(ChangeField.UPDATED, ChangeQueryBuilder.FIELD_BEFORE, value);
+  public BeforePredicate(FieldDef<ChangeData, Timestamp> def, String name, String value)
+      throws QueryParseException {
+    super(def, name, value);
     cut = parse(value);
   }
 
@@ -38,6 +44,10 @@
 
   @Override
   public boolean match(ChangeData cd) {
-    return cd.change().getLastUpdatedOn().getTime() <= cut.getTime();
+    Timestamp valueTimestamp = this.getValueTimestamp(cd);
+    if (valueTimestamp == null) {
+      return false;
+    }
+    return valueTimestamp.getTime() <= cut.getTime();
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 432b48a..bf56000 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -26,13 +26,16 @@
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
+import com.google.common.collect.SetMultimap;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
@@ -43,6 +46,7 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.entities.SubmitRecord;
@@ -50,6 +54,7 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.index.RefState;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
@@ -69,6 +74,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.RobotCommentNotes;
 import com.google.gerrit.server.patch.DiffSummary;
 import com.google.gerrit.server.patch.DiffSummaryKey;
 import com.google.gerrit.server.patch.PatchListCache;
@@ -103,6 +109,13 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
+/**
+ * ChangeData provides lazily loaded interface to change metadata loaded from NoteDb. It can be
+ * constructed by loading from NoteDb, or calling setters. The latter happens when ChangeData is
+ * retrieved through the change index. This happens for Applications that are performance sensitive
+ * (eg. dashboard loads, git protocol negotiation) but can tolerate staleness. In that case, setting
+ * lazyLoad=false disables loading from NoteDb, so we don't accidentally enable a slow path.
+ */
 public class ChangeData {
   public static List<Change> asChanges(List<ChangeData> changeDatas) {
     List<Change> result = new ArrayList<>(changeDatas.size());
@@ -307,8 +320,8 @@
   private Integer unresolvedCommentCount;
   private Integer totalCommentCount;
   private LabelTypes labelTypes;
-
-  private ImmutableList<byte[]> refStates;
+  private Optional<Timestamp> mergedOn;
+  private ImmutableSetMultimap<NameKey, RefState> refStates;
   private ImmutableList<byte[]> refStatePatterns;
 
   @Inject
@@ -594,6 +607,7 @@
       author = c.getAuthorIdent();
       committer = c.getCommitterIdent();
       parentCount = c.getParentCount();
+      merge = parentCount > 0;
     } catch (IOException e) {
       throw new StorageException(
           String.format(
@@ -616,6 +630,29 @@
   }
 
   /**
+   * Returns the {@link Optional} value of time when the change was merged.
+   *
+   * <p>The value can be set from index field, see {@link ChangeData#setMergedOn} or loaded from the
+   * database (available in {@link ChangeNotes})
+   *
+   * @return {@link Optional} value of time when the change was merged.
+   * @throws StorageException if {@code lazyLoad} is off, {@link ChangeNotes} can not be loaded
+   *     because we do not expect to call the database.
+   */
+  public Optional<Timestamp> getMergedOn() throws StorageException {
+    if (mergedOn == null) {
+      // The value was not loaded yet, try to get from the database.
+      mergedOn = notes().getMergedOn();
+    }
+    return mergedOn;
+  }
+
+  /** Sets the value e.g. when loading from index. */
+  public void setMergedOn(@Nullable Timestamp mergedOn) {
+    this.mergedOn = Optional.ofNullable(mergedOn);
+  }
+
+  /**
    * Sets the specified attention set. If two or more entries refer to the same user, throws an
    * {@link IllegalStateException}.
    */
@@ -670,7 +707,9 @@
     return allApprovals;
   }
 
-  /** @return The submit ('SUBM') approval label */
+  /* @return legacy submit ('SUBM') approval label */
+  // TODO(mariasavtchouk): Deprecate legacy submit label,
+  // see com.google.gerrit.entities.LabelId.LEGACY_SUBMIT_NAME
   public Optional<PatchSetApproval> getSubmitApproval() {
     return currentApprovals().stream().filter(PatchSetApproval::isLegacySubmit).findFirst();
   }
@@ -833,36 +872,25 @@
   }
 
   public List<SubmitRecord> submitRecords(SubmitRuleOptions options) {
-    List<SubmitRecord> records = getCachedSubmitRecord(options);
+    // If the change is not submitted yet, 'strict' and 'lenient' both have the same result. If the
+    // change is submitted, SubmitRecord requested with 'strict' will contain just a single entry
+    // that with status=CLOSED. The latter is cheap to evaluate as we don't have to run any actual
+    // evaluation.
+    List<SubmitRecord> records = submitRecords.get(options);
     if (records == null) {
       if (!lazyLoad) {
         return Collections.emptyList();
       }
       records = submitRuleEvaluatorFactory.create(options).evaluate(this);
       submitRecords.put(options, records);
+      if (!change().isClosed() && submitRecords.size() == 1) {
+        // Cache the SubmitRecord with allowClosed = !allowClosed as the SubmitRecord are the same.
+        submitRecords.put(options.toBuilder().allowClosed(!options.allowClosed()).build(), records);
+      }
     }
     return records;
   }
 
-  @Nullable
-  public List<SubmitRecord> getSubmitRecords(SubmitRuleOptions options) {
-    return getCachedSubmitRecord(options);
-  }
-
-  private List<SubmitRecord> getCachedSubmitRecord(SubmitRuleOptions options) {
-    List<SubmitRecord> records = submitRecords.get(options);
-    if (records != null) {
-      return records;
-    }
-
-    if (options.allowClosed() && change != null && change.getStatus().isOpen()) {
-      SubmitRuleOptions openSubmitRuleOptions = options.toBuilder().allowClosed(false).build();
-      return submitRecords.get(openSubmitRuleOptions);
-    }
-
-    return null;
-  }
-
   public void setSubmitRecords(SubmitRuleOptions options, List<SubmitRecord> records) {
     submitRecords.put(options, records);
   }
@@ -930,7 +958,7 @@
         return null;
       }
     }
-    return parentCount > 1;
+    return merge;
   }
 
   public Set<Account.Id> editsByUser() {
@@ -1144,12 +1172,38 @@
     }
   }
 
-  public ImmutableList<byte[]> getRefStates() {
+  public SetMultimap<NameKey, RefState> getRefStates() {
+    if (refStates == null) {
+      if (!lazyLoad) {
+        return ImmutableSetMultimap.of();
+      }
+
+      ImmutableSetMultimap.Builder<NameKey, RefState> result = ImmutableSetMultimap.builder();
+      editRefs().values().forEach(r -> result.put(project, RefState.of(r)));
+      starRefs().values().forEach(r -> result.put(allUsersName, RefState.of(r.ref())));
+
+      // TODO: instantiating the notes is too much. We don't want to parse NoteDb, we just want the
+      // refs.
+      result.put(project, RefState.create(notes().getRefName(), notes().getMetaId()));
+      notes().getRobotComments(); // Force loading robot comments.
+      RobotCommentNotes robotNotes = notes().getRobotCommentNotes();
+      result.put(project, RefState.create(robotNotes.getRefName(), robotNotes.getMetaId()));
+      draftRefs().values().forEach(r -> result.put(allUsersName, RefState.of(r)));
+
+      refStates = result.build();
+    }
+
     return refStates;
   }
 
+  @UsedAt(UsedAt.Project.GOOGLE)
   public void setRefStates(Iterable<byte[]> refStates) {
-    this.refStates = ImmutableList.copyOf(refStates);
+    // TODO(hanwen): remove Google use, and drop this method.
+    setRefStates(RefState.parseStates(refStates));
+  }
+
+  public void setRefStates(ImmutableSetMultimap<Project.NameKey, RefState> refStates) {
+    this.refStates = refStates;
   }
 
   public ImmutableList<byte[]> getRefStatePatterns() {
diff --git a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
index c6bcd60..a66c43ae 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
@@ -88,7 +88,7 @@
             ? permissionBackend.absentUser(user.getAccountId())
             : permissionBackend.user(
                 Optional.of(user)
-                    .filter(u -> u instanceof SingleGroupUser || u instanceof InternalUser)
+                    .filter(u -> u instanceof GroupBackedUser || u instanceof InternalUser)
                     .orElseGet(anonymousUserProvider::get));
     try {
       withUser.change(cd).check(ChangePermission.READ);
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 2021b2b..4e3edcd 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -142,7 +142,7 @@
   public static final String FIELD_ASSIGNEE = "assignee";
   public static final String FIELD_AUTHOR = "author";
   public static final String FIELD_EXACTAUTHOR = "exactauthor";
-  public static final String FIELD_BEFORE = "before";
+
   public static final String FIELD_CHANGE = "change";
   public static final String FIELD_CHANGE_ID = "change_id";
   public static final String FIELD_COMMENT = "comment";
@@ -169,9 +169,11 @@
   public static final String FIELD_LIMIT = "limit";
   public static final String FIELD_MERGE = "merge";
   public static final String FIELD_MERGEABLE = "mergeable2";
+  public static final String FIELD_MERGED_ON = "mergedon";
   public static final String FIELD_MESSAGE = "message";
   public static final String FIELD_OWNER = "owner";
   public static final String FIELD_OWNERIN = "ownerin";
+  public static final String FIELD_PARENTOF = "parentof";
   public static final String FIELD_PARENTPROJECT = "parentproject";
   public static final String FIELD_PATH = "path";
   public static final String FIELD_PENDING_REVIEWER = "pendingreviewer";
@@ -199,11 +201,19 @@
   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";
   public static final Account.Id OWNER_ACCOUNT_ID = Account.id(0);
 
+  public static final String OPERATOR_MERGED_BEFORE = "mergedbefore";
+  public static final String OPERATOR_MERGED_AFTER = "mergedafter";
+
+  // Operators to match on the last time the change was updated. Naming for legacy reasons.
+  public static final String OPERATOR_BEFORE = "before";
+  public static final String OPERATOR_AFTER = "after";
+
   private static final QueryBuilder.Definition<ChangeData, ChangeQueryBuilder> mydef =
       new QueryBuilder.Definition<>(ChangeQueryBuilder.class);
 
@@ -470,7 +480,7 @@
 
   @Operator
   public Predicate<ChangeData> before(String value) throws QueryParseException {
-    return new BeforePredicate(value);
+    return new BeforePredicate(ChangeField.UPDATED, ChangeQueryBuilder.OPERATOR_BEFORE, value);
   }
 
   @Operator
@@ -480,7 +490,7 @@
 
   @Operator
   public Predicate<ChangeData> after(String value) throws QueryParseException {
-    return new AfterPredicate(value);
+    return new AfterPredicate(ChangeField.UPDATED, ChangeQueryBuilder.OPERATOR_AFTER, value);
   }
 
   @Operator
@@ -489,6 +499,28 @@
   }
 
   @Operator
+  public Predicate<ChangeData> mergedBefore(String value) throws QueryParseException {
+    if (!args.index.getSchema().hasField(ChangeField.MERGED_ON)) {
+      throw new QueryParseException(
+          String.format(
+              "'%s' operator is not supported by change index version", OPERATOR_MERGED_BEFORE));
+    }
+    return new BeforePredicate(
+        ChangeField.MERGED_ON, ChangeQueryBuilder.OPERATOR_MERGED_BEFORE, value);
+  }
+
+  @Operator
+  public Predicate<ChangeData> mergedAfter(String value) throws QueryParseException {
+    if (!args.index.getSchema().hasField(ChangeField.MERGED_ON)) {
+      throw new QueryParseException(
+          String.format(
+              "'%s' operator is not supported by change index version", OPERATOR_MERGED_AFTER));
+    }
+    return new AfterPredicate(
+        ChangeField.MERGED_ON, ChangeQueryBuilder.OPERATOR_MERGED_AFTER, value);
+  }
+
+  @Operator
   public Predicate<ChangeData> change(String query) throws QueryParseException {
     Optional<ChangeTriplet> triplet = ChangeTriplet.parse(query);
     if (triplet.isPresent()) {
@@ -704,6 +736,16 @@
   }
 
   @Operator
+  public Predicate<ChangeData> parentof(String value) throws QueryParseException {
+    List<ChangeData> changes = parseChangeData(value);
+    List<Predicate<ChangeData>> or = new ArrayList<>(changes.size());
+    for (ChangeData c : changes) {
+      or.add(new ParentOfPredicate(value, c, args.repoManager));
+    }
+    return Predicate.or(or);
+  }
+
+  @Operator
   public Predicate<ChangeData> parentproject(String name) {
     return new ParentProjectPredicate(args.projectCache, args.childProjects, name);
   }
@@ -1034,7 +1076,7 @@
       for (GroupReference ref : suggestions) {
         ids.add(ref.getUUID());
       }
-      return visibleto(new SingleGroupUser(ids));
+      return visibleto(new GroupBackedUser(ids));
     }
 
     throw error("No user or group matches \"" + who + "\".");
@@ -1232,9 +1274,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) {
@@ -1244,7 +1313,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);
   }
@@ -1256,19 +1325,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);
   }
@@ -1475,14 +1571,18 @@
   }
 
   private List<Change> parseChange(String value) throws QueryParseException {
+    return asChanges(parseChangeData(value));
+  }
+
+  private List<ChangeData> parseChangeData(String value) throws QueryParseException {
     if (PAT_LEGACY_ID.matcher(value).matches()) {
       Optional<Change.Id> id = Change.Id.tryParse(value);
       if (!id.isPresent()) {
         throw error("Invalid change id " + value);
       }
-      return asChanges(args.queryProvider.get().byLegacyChangeId(id.get()));
+      return args.queryProvider.get().byLegacyChangeId(id.get());
     } else if (PAT_CHANGE_ID.matcher(value).matches()) {
-      List<Change> changes = asChanges(args.queryProvider.get().byKeyPrefix(parseChangeId(value)));
+      List<ChangeData> changes = args.queryProvider.get().byKeyPrefix(parseChangeId(value));
       if (changes.isEmpty()) {
         throw error("Change " + value + " not found");
       }
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
index 370bc75..ed1f2f1 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
@@ -17,7 +17,6 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_LIMIT;
 
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
@@ -33,10 +32,8 @@
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.DynamicOptions.DynamicBean;
 import com.google.gerrit.server.account.AccountLimits;
-import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
 import com.google.gerrit.server.change.PluginDefinedAttributesFactories;
-import com.google.gerrit.server.change.PluginDefinedAttributesFactory;
 import com.google.gerrit.server.change.PluginDefinedInfosFactory;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexRewriter;
@@ -60,7 +57,6 @@
 public class ChangeQueryProcessor extends QueryProcessor<ChangeData>
     implements DynamicOptions.BeanReceiver, DynamicOptions.BeanProvider, PluginDefinedInfosFactory {
   private final Provider<CurrentUser> userProvider;
-  private final ImmutableListMultimap<String, ChangeAttributeFactory> attributeFactoriesByPlugin;
   private final ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory;
   private final Map<String, DynamicBean> dynamicBeans = new HashMap<>();
   private final List<Extension<ChangePluginDefinedInfoFactory>>
@@ -81,7 +77,6 @@
       IndexConfig indexConfig,
       ChangeIndexCollection indexes,
       ChangeIndexRewriter rewriter,
-      DynamicSet<ChangeAttributeFactory> attributeFactories,
       ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory,
       DynamicSet<ChangePluginDefinedInfoFactory> changePluginDefinedInfoFactories) {
     super(
@@ -95,14 +90,6 @@
     this.userProvider = userProvider;
     this.changeIsVisibleToPredicateFactory = changeIsVisibleToPredicateFactory;
 
-    ImmutableListMultimap.Builder<String, ChangeAttributeFactory> factoriesBuilder =
-        ImmutableListMultimap.builder();
-    ImmutableListMultimap.Builder<String, ChangePluginDefinedInfoFactory> infosFactoriesBuilder =
-        ImmutableListMultimap.builder();
-    // Eagerly call Extension#get() rather than storing Extensions, since that method invokes the
-    // Provider on every call, which could be expensive if we invoke it once for every change.
-    attributeFactories.entries().forEach(e -> factoriesBuilder.put(e.getPluginName(), e.get()));
-    attributeFactoriesByPlugin = factoriesBuilder.build();
     changePluginDefinedInfoFactories
         .entries()
         .forEach(e -> changePluginDefinedInfoFactoriesByPlugin.add(e));
@@ -130,18 +117,6 @@
     return dynamicBeans.get(plugin);
   }
 
-  public PluginDefinedAttributesFactory getAttributesFactory() {
-    return this::buildPluginInfo;
-  }
-
-  private ImmutableList<PluginDefinedInfo> buildPluginInfo(ChangeData cd) {
-    return PluginDefinedAttributesFactories.createAll(
-        cd,
-        this,
-        attributeFactoriesByPlugin.entries().stream()
-            .map(e -> new Extension<>(e.getKey(), e::getValue)));
-  }
-
   public PluginDefinedInfosFactory getInfosFactory() {
     return this::createPluginDefinedInfos;
   }
diff --git a/java/com/google/gerrit/server/query/change/GroupBackedUser.java b/java/com/google/gerrit/server/query/change/GroupBackedUser.java
new file mode 100644
index 0000000..d0d5735
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/GroupBackedUser.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.GroupMembership;
+import com.google.gerrit.server.account.ListGroupMembership;
+import java.util.Set;
+
+/**
+ * Representation of a user that does not have a Gerrit account.
+ *
+ * <p>This user representation is intended to be used to check permissions for groups:
+ *
+ * <p>There are occasions where we need to check if a resource - such as a change - is accessible by
+ * a group. Our entire {@link com.google.gerrit.server.permissions.PermissionBackend} works solely
+ * with {@link CurrentUser}. This class can be used to check permissions on a synthetic user with
+ * the given group memberships. Any real Gerrit user with the same group memberships would receive
+ * the same permission check results.
+ */
+public final class GroupBackedUser extends CurrentUser {
+  private final GroupMembership groups;
+
+  /**
+   * Creates a new instance
+   *
+   * @param groups this set has to include all parent groups the user is contained in through
+   *     subgroup membership. Given a set of groups that contains the user directly, callers can use
+   *     {@link
+   *     com.google.gerrit.server.account.GroupIncludeCache#parentGroupsOf(AccountGroup.UUID)} to
+   *     resolve parent groups.
+   */
+  public GroupBackedUser(Set<AccountGroup.UUID> groups) {
+    this.groups = new ListGroupMembership(groups);
+  }
+
+  @Override
+  public GroupMembership getEffectiveGroups() {
+    return groups;
+  }
+
+  @Override
+  public String getLoggableName() {
+    return "GroupBackedUser with memberships: " + groups.getKnownGroups();
+  }
+
+  @Override
+  public Object getCacheKey() {
+    return groups.getKnownGroups();
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
index b931457..4922b57 100644
--- a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
+++ b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
@@ -330,15 +330,9 @@
       eventFactory.addDependencies(rw, c, d.change(), d.currentPatchSet());
     }
 
-    c.plugins = queryProcessor.getAttributesFactory().create(d);
     List<PluginDefinedInfo> pluginInfos = pluginInfosByChange.get(d.getId());
     if (!pluginInfos.isEmpty()) {
-      if (c.plugins == null) {
-        c.plugins = pluginInfos;
-      } else {
-        c.plugins = new ArrayList<>(c.plugins);
-        c.plugins.addAll(pluginInfos);
-      }
+      c.plugins = pluginInfos;
     }
     return c;
   }
diff --git a/java/com/google/gerrit/server/query/change/ParentOfPredicate.java b/java/com/google/gerrit/server/query/change/ParentOfPredicate.java
new file mode 100644
index 0000000..e48d586
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/ParentOfPredicate.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.common.collect.Sets;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.query.Matchable;
+import com.google.gerrit.index.query.OperatorPredicate;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import java.io.IOException;
+import java.util.Set;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+public class ParentOfPredicate extends OperatorPredicate<ChangeData>
+    implements Matchable<ChangeData> {
+  protected final Set<RevCommit> parents;
+
+  public ParentOfPredicate(String value, ChangeData change, GitRepositoryManager repoManager) {
+    super(ChangeQueryBuilder.FIELD_PARENTOF, value);
+    this.parents = getParents(change, repoManager);
+  }
+
+  @Override
+  public boolean match(ChangeData changeData) {
+    return changeData.patchSets().stream().anyMatch(ps -> parents.contains(ps.commitId()));
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+
+  protected Set<RevCommit> getParents(ChangeData change, GitRepositoryManager repoManager) {
+    PatchSet ps = change.currentPatchSet();
+    try (Repository repo = repoManager.openRepository(change.project());
+        RevWalk walk = new RevWalk(repo)) {
+      RevCommit c = walk.parseCommit(ps.commitId());
+      return Sets.newHashSet(c.getParents());
+    } catch (IOException e) {
+      throw new StorageException(
+          String.format(
+              "Loading commit %s for ps %d of change %d failed.",
+              ps.commitId(), ps.id().get(), ps.id().changeId().get()),
+          e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/SingleGroupUser.java b/java/com/google/gerrit/server/query/change/SingleGroupUser.java
deleted file mode 100644
index c451d46..0000000
--- a/java/com/google/gerrit/server/query/change/SingleGroupUser.java
+++ /dev/null
@@ -1,44 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.entities.AccountGroup;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.GroupMembership;
-import com.google.gerrit.server.account.ListGroupMembership;
-import java.util.Set;
-
-public final class SingleGroupUser extends CurrentUser {
-  private final GroupMembership groups;
-
-  public SingleGroupUser(AccountGroup.UUID groupId) {
-    this(ImmutableSet.of(groupId));
-  }
-
-  public SingleGroupUser(Set<AccountGroup.UUID> groups) {
-    this.groups = new ListGroupMembership(groups);
-  }
-
-  @Override
-  public GroupMembership getEffectiveGroups() {
-    return groups;
-  }
-
-  @Override
-  public Object getCacheKey() {
-    return groups.getKnownGroups();
-  }
-}
diff --git a/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java b/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java
index 66eab7b..8e6d8a1 100644
--- a/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java
@@ -38,6 +38,8 @@
  *
  * <p>Instances are one-time-use. Other singleton classes should inject a Provider rather than
  * holding on to a single instance.
+ *
+ * <p>By default, enforces visibility to CurrentUser.
  */
 public class ProjectQueryProcessor extends QueryProcessor<ProjectData> {
   private final PermissionBackend permissionBackend;
diff --git a/java/com/google/gerrit/server/restapi/BUILD b/java/com/google/gerrit/server/restapi/BUILD
index 3f28a03..6d3e222 100644
--- a/java/com/google/gerrit/server/restapi/BUILD
+++ b/java/com/google/gerrit/server/restapi/BUILD
@@ -18,13 +18,11 @@
         "//java/com/google/gerrit/index:query_exception",
         "//java/com/google/gerrit/index/project",
         "//java/com/google/gerrit/json",
-        "//java/com/google/gerrit/mail",
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/ioutil",
         "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/util/time",
-        "//java/com/google/gerrit/util/cli",
         "//lib:args4j",
         "//lib:blame-cache",
         "//lib:gson",
diff --git a/java/com/google/gerrit/server/restapi/RestApiModule.java b/java/com/google/gerrit/server/restapi/RestApiModule.java
index dc2fe5f..909c1f4 100644
--- a/java/com/google/gerrit/server/restapi/RestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/RestApiModule.java
@@ -29,5 +29,6 @@
     install(new com.google.gerrit.server.restapi.group.Module());
     install(new PluginRestApiModule());
     install(new com.google.gerrit.server.restapi.project.Module());
+    install(new com.google.gerrit.server.restapi.project.Module.BatchModule());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/AccountsCollection.java b/java/com/google/gerrit/server/restapi/account/AccountsCollection.java
index 119e2e4..61ff6b8 100644
--- a/java/com/google/gerrit/server/restapi/account/AccountsCollection.java
+++ b/java/com/google/gerrit/server/restapi/account/AccountsCollection.java
@@ -53,6 +53,7 @@
       return new AccountResource(accountResolver.resolve(id.get()).asUniqueUser());
     } catch (UnresolvableAccountException e) {
       if (e.isSelf()) {
+        // Must be authenticated to use 'me' or 'self'.
         throw new AuthException(e.getMessage(), e);
       }
       throw new ResourceNotFoundException(e.getMessage(), e);
diff --git a/java/com/google/gerrit/server/restapi/account/GetSshKeys.java b/java/com/google/gerrit/server/restapi/account/GetSshKeys.java
index 6df6c3c..9952987 100644
--- a/java/com/google/gerrit/server/restapi/account/GetSshKeys.java
+++ b/java/com/google/gerrit/server/restapi/account/GetSshKeys.java
@@ -60,8 +60,7 @@
 
   @Override
   public Response<List<SshKeyInfo>> apply(AccountResource rsrc)
-      throws AuthException, RepositoryNotFoundException, IOException, ConfigInvalidException,
-          PermissionBackendException {
+      throws AuthException, IOException, ConfigInvalidException, PermissionBackendException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
     }
diff --git a/java/com/google/gerrit/server/restapi/account/Module.java b/java/com/google/gerrit/server/restapi/account/Module.java
index 51055b8..7570465 100644
--- a/java/com/google/gerrit/server/restapi/account/Module.java
+++ b/java/com/google/gerrit/server/restapi/account/Module.java
@@ -76,8 +76,6 @@
     get(SSH_KEY_KIND).to(GetSshKey.class);
     delete(SSH_KEY_KIND).to(DeleteSshKey.class);
 
-    get(ACCOUNT_KIND, "oauthtoken").to(GetOAuthToken.class);
-
     get(ACCOUNT_KIND, "avatar").to(GetAvatar.class);
     get(ACCOUNT_KIND, "avatar.change.url").to(GetAvatarChangeUrl.class);
 
diff --git a/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java b/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
index c80bf57..5979b2a 100644
--- a/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
+++ b/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
@@ -91,7 +91,7 @@
       throws RestApiException, IOException, PermissionBackendException {
     Map<ProjectWatchKey, Set<NotifyType>> m = new HashMap<>();
     for (ProjectWatchInfo info : input) {
-      if (info.project == null) {
+      if (info.project == null || info.project.trim().isEmpty()) {
         throw new BadRequestException("project name must be specified");
       }
 
diff --git a/java/com/google/gerrit/server/restapi/account/PutActive.java b/java/com/google/gerrit/server/restapi/account/PutActive.java
index a80ab3f..3b431db 100644
--- a/java/com/google/gerrit/server/restapi/account/PutActive.java
+++ b/java/com/google/gerrit/server/restapi/account/PutActive.java
@@ -32,7 +32,7 @@
  *
  * <p>This REST endpoint handles {@code PUT /accounts/<account-identifier>/active} requests.
  *
- * <p>Only active accounts can login into Gerrit.
+ * <p>Only active accounts can login into Gerrit, or are suggested as reviewers.
  *
  * <p>Marking an account as inactive is handled by {@link DeleteActive}.
  */
diff --git a/java/com/google/gerrit/server/restapi/account/StarredChanges.java b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
index c108dcb..e67fe9e 100644
--- a/java/com/google/gerrit/server/restapi/account/StarredChanges.java
+++ b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
@@ -17,6 +17,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -96,8 +97,7 @@
 
   @Singleton
   public static class Create
-      implements RestCollectionCreateView<
-          AccountResource, AccountResource.StarredChange, EmptyInput> {
+      implements RestCollectionCreateView<AccountResource, AccountResource.StarredChange, Input> {
     private final Provider<CurrentUser> self;
     private final ChangesCollection changes;
     private final StarredChangesUtil starredChangesUtil;
@@ -113,7 +113,7 @@
     }
 
     @Override
-    public Response<?> apply(AccountResource rsrc, IdString id, EmptyInput in)
+    public Response<?> apply(AccountResource rsrc, IdString id, Input in)
         throws RestApiException, IOException {
       if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed to add starred change");
@@ -148,7 +148,7 @@
   }
 
   @Singleton
-  public static class Put implements RestModifyView<AccountResource.StarredChange, EmptyInput> {
+  public static class Put implements RestModifyView<AccountResource.StarredChange, Input> {
     private final Provider<CurrentUser> self;
 
     @Inject
@@ -157,8 +157,7 @@
     }
 
     @Override
-    public Response<?> apply(AccountResource.StarredChange rsrc, EmptyInput in)
-        throws AuthException {
+    public Response<?> apply(AccountResource.StarredChange rsrc, Input in) throws AuthException {
       if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed update starred changes");
       }
@@ -167,7 +166,7 @@
   }
 
   @Singleton
-  public static class Delete implements RestModifyView<AccountResource.StarredChange, EmptyInput> {
+  public static class Delete implements RestModifyView<AccountResource.StarredChange, Input> {
     private final Provider<CurrentUser> self;
     private final StarredChangesUtil starredChangesUtil;
 
@@ -178,7 +177,7 @@
     }
 
     @Override
-    public Response<?> apply(AccountResource.StarredChange rsrc, EmptyInput in)
+    public Response<?> apply(AccountResource.StarredChange rsrc, Input in)
         throws AuthException, IOException, IllegalLabelException {
       if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed remove starred change");
@@ -192,6 +191,4 @@
       return Response.none();
     }
   }
-
-  public static class EmptyInput {}
 }
diff --git a/java/com/google/gerrit/server/restapi/account/Stars.java b/java/com/google/gerrit/server/restapi/account/Stars.java
index c27bdd8..cc362f2 100644
--- a/java/com/google/gerrit/server/restapi/account/Stars.java
+++ b/java/com/google/gerrit/server/restapi/account/Stars.java
@@ -46,6 +46,12 @@
 import java.util.Set;
 import java.util.SortedSet;
 
+/**
+ * Implements adding label stars to changes.
+ *
+ * <p>This handles {@code POST} and {@code GET} for {@code
+ * /accounts/<account-identifier>/stars.changes/<change ID>}.
+ */
 @Singleton
 public class Stars implements ChildCollection<AccountResource, AccountResource.Star> {
 
@@ -70,6 +76,7 @@
   public Star parse(AccountResource parent, IdString id)
       throws RestApiException, PermissionBackendException, IOException {
     IdentifiedUser user = parent.getUser();
+    // This enforces visibility of the change.
     ChangeResource change = changes.parse(TopLevelResource.INSTANCE, id);
     Set<String> labels = starredChangesUtil.getLabels(user.getAccountId(), change.getId());
     return new AccountResource.Star(user, change, labels);
@@ -87,6 +94,7 @@
 
   @Singleton
   public static class ListStarredChanges implements RestReadView<AccountResource> {
+
     private final Provider<CurrentUser> self;
     private final ChangesCollection changes;
 
@@ -121,6 +129,7 @@
 
   @Singleton
   public static class Get implements RestReadView<AccountResource.Star> {
+
     private final Provider<CurrentUser> self;
     private final StarredChangesUtil starredChangesUtil;
 
@@ -142,6 +151,7 @@
 
   @Singleton
   public static class Post implements RestModifyView<AccountResource.Star, StarsInput> {
+
     private final Provider<CurrentUser> self;
     private final StarredChangesUtil starredChangesUtil;
 
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/ChangeEdits.java b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
index 7a15a1d..318b0fa 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
@@ -210,7 +210,7 @@
         }
         try {
           editInfo.files =
-              fileInfoJson.toFileInfoMap(
+              fileInfoJson.getFileInfoMap(
                   rsrc.getChange(), edit.get().getEditCommit(), basePatchSet);
         } catch (PatchListNotAvailableException e) {
           throw new ResourceNotFoundException(e.getMessage());
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index fdac552..ee6484c 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -28,7 +28,6 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -42,8 +41,8 @@
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.change.ResetCherryPickOp;
 import com.google.gerrit.server.change.SetCherryPickOp;
-import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -113,7 +112,6 @@
   private final ApprovalsUtil approvalsUtil;
   private final NotifyResolver notifyResolver;
   private final BatchUpdate.Factory batchUpdateFactory;
-  private final DynamicItem<UrlFormatter> urlFormatter;
 
   @Inject
   CherryPickChange(
@@ -130,8 +128,7 @@
       ProjectCache projectCache,
       ApprovalsUtil approvalsUtil,
       NotifyResolver notifyResolver,
-      BatchUpdate.Factory batchUpdateFactory,
-      DynamicItem<UrlFormatter> urlFormatter) {
+      BatchUpdate.Factory batchUpdateFactory) {
     this.seq = seq;
     this.queryProvider = queryProvider;
     this.gitManager = gitManager;
@@ -146,7 +143,6 @@
     this.approvalsUtil = approvalsUtil;
     this.notifyResolver = notifyResolver;
     this.batchUpdateFactory = batchUpdateFactory;
-    this.urlFormatter = urlFormatter;
   }
 
   /**
@@ -281,20 +277,36 @@
                 input.parent, commitToCherryPick.getParentCount()));
       }
 
-      String message = Strings.nullToEmpty(input.message).trim();
-      message = message.isEmpty() ? commitToCherryPick.getFullMessage() : message;
+      // If the commit message is not set, the commit message of the source commit will be used.
+      String commitMessage = Strings.nullToEmpty(input.message);
+      commitMessage = commitMessage.isEmpty() ? commitToCherryPick.getFullMessage() : commitMessage;
 
-      PersonIdent committerIdent = identifiedUser.newCommitterIdent(timestamp, serverTimeZone);
+      String destChangeId = getDestinationChangeId(commitMessage, changeIdForNewChange);
 
-      final ObjectId generatedChangeId =
-          changeIdForNewChange != null
-              ? changeIdForNewChange
-              : CommitMessageUtil.generateChangeId();
-      String commitMessage = ChangeIdUtil.insertId(message, generatedChangeId).trim() + '\n';
+      ChangeData destChange = null;
+      if (destChangeId != null) {
+        // If "idForNewChange" is not null we must fail, since we are not expecting an already
+        // existing change.
+        destChange = getDestChangeWithVerification(destChangeId, dest, idForNewChange != null);
+      }
+
+      if (changeIdForNewChange != null) {
+        // If Change-Id was explicitly provided for the new change, override the value in commit
+        // message.
+        commitMessage = ChangeIdUtil.insertId(commitMessage, changeIdForNewChange, true);
+      } else if (destChangeId == null) {
+        // If commit message did not specify Change-Id, generate a new one and insert to the
+        // message.
+        commitMessage =
+            ChangeIdUtil.insertId(commitMessage, CommitMessageUtil.generateChangeId(), true);
+      }
+      commitMessage = CommitMessageUtil.checkAndSanitizeCommitMessage(commitMessage);
 
       CodeReviewCommit cherryPickCommit;
       ProjectState projectState =
           projectCache.get(dest.project()).orElseThrow(noSuchProject(dest.project()));
+      PersonIdent committerIdent = identifiedUser.newCommitterIdent(timestamp, serverTimeZone);
+
       try {
         MergeUtil mergeUtil;
         if (input.allowConflicts) {
@@ -316,86 +328,54 @@
                 input.parent - 1,
                 input.allowEmpty,
                 input.allowConflicts);
-
-        Change.Key changeKey;
-        final List<String> idList =
-            ChangeUtil.getChangeIdsFromFooter(cherryPickCommit, urlFormatter.get());
-        if (!idList.isEmpty()) {
-          final String idStr = idList.get(idList.size() - 1).trim();
-          changeKey = Change.key(idStr);
-        } else {
-          changeKey = Change.key("I" + generatedChangeId.name());
-        }
-
-        BranchNameKey newDest = BranchNameKey.create(project, destRef.getName());
-        List<ChangeData> destChanges =
-            queryProvider.get().setLimit(2).byBranchKey(newDest, changeKey);
-        if (destChanges.size() > 1) {
-          throw new InvalidChangeOperationException(
-              "Several changes with key "
-                  + changeKey
-                  + " reside on the same branch. "
-                  + "Cannot create a new patch set.");
-        }
-        try (BatchUpdate bu = batchUpdateFactory.create(project, identifiedUser, timestamp)) {
-          bu.setRepository(git, revWalk, oi);
-          bu.setNotify(resolveNotify(input));
-          Change.Id changeId;
-          String newTopic = null;
-          if (input.topic != null) {
-            newTopic = Strings.emptyToNull(input.topic.trim());
-          }
-          if (newTopic == null
-              && sourceChange != null
-              && !Strings.isNullOrEmpty(sourceChange.getTopic())) {
-            newTopic = sourceChange.getTopic() + "-" + newDest.shortName();
-          }
-          if (destChanges.size() == 1) {
-            // The change key exists on the destination branch. The cherry pick
-            // will be added as a new patch set. If "idForNewChange" is not null we must fail,
-            // since we are not expecting an already existing change.
-            if (idForNewChange != null) {
-              throw new InvalidChangeOperationException(
-                  String.format(
-                      "Expected that cherry-pick of commit %s with Change-Id %s to branch %s"
-                          + "in project %s creates a new change, but found existing change %d",
-                      sourceCommit.getName(),
-                      changeKey,
-                      dest.branch(),
-                      dest.project(),
-                      destChanges.get(0).getId().get()));
-            }
-            changeId =
-                insertPatchSet(
-                    bu,
-                    git,
-                    destChanges.get(0).notes(),
-                    cherryPickCommit,
-                    sourceChange.currentPatchSetId(),
-                    newTopic,
-                    workInProgress);
-          } else {
-            // Change key not found on destination branch. We can create a new
-            // change.
-            changeId =
-                createNewChange(
-                    bu,
-                    cherryPickCommit,
-                    dest.branch(),
-                    newTopic,
-                    project,
-                    sourceChange,
-                    sourceCommit,
-                    input,
-                    revertedChange,
-                    idForNewChange,
-                    workInProgress);
-          }
-          bu.execute();
-          return Result.create(changeId, cherryPickCommit.getFilesWithGitConflicts());
-        }
       } catch (MergeIdenticalTreeException | MergeConflictException e) {
-        throw new IntegrationConflictException("Cherry pick failed: " + e.getMessage());
+        throw new IntegrationConflictException("Cherry pick failed: " + e.getMessage(), e);
+      }
+
+      try (BatchUpdate bu = batchUpdateFactory.create(project, identifiedUser, timestamp)) {
+        bu.setRepository(git, revWalk, oi);
+        bu.setNotify(resolveNotify(input));
+        Change.Id changeId;
+        String newTopic = null;
+        if (input.topic != null) {
+          newTopic = Strings.emptyToNull(input.topic.trim());
+        }
+        if (newTopic == null
+            && sourceChange != null
+            && !Strings.isNullOrEmpty(sourceChange.getTopic())) {
+          newTopic = sourceChange.getTopic() + "-" + dest.shortName();
+        }
+        if (destChange != null) {
+          // The change key exists on the destination branch. The cherry pick
+          // will be added as a new patch set.
+          changeId =
+              insertPatchSet(
+                  bu,
+                  git,
+                  destChange.notes(),
+                  cherryPickCommit,
+                  sourceChange,
+                  newTopic,
+                  workInProgress);
+        } else {
+          // Change key not found on destination branch. We can create a new
+          // change.
+          changeId =
+              createNewChange(
+                  bu,
+                  cherryPickCommit,
+                  dest.branch(),
+                  newTopic,
+                  project,
+                  sourceChange,
+                  sourceCommit,
+                  input,
+                  revertedChange,
+                  idForNewChange,
+                  workInProgress);
+        }
+        bu.execute();
+        return Result.create(changeId, cherryPickCommit.getFilesWithGitConflicts());
       }
     }
   }
@@ -456,7 +436,7 @@
       Repository git,
       ChangeNotes destNotes,
       CodeReviewCommit cherryPickCommit,
-      PatchSet.Id sourcePatchSetId,
+      @Nullable Change sourceChange,
       String topic,
       @Nullable Boolean workInProgress)
       throws IOException {
@@ -469,7 +449,11 @@
       inserter.setWorkInProgress(workInProgress);
     }
     bu.addOp(destChange.getId(), inserter);
-    if (destChange.getCherryPickOf() == null
+    PatchSet.Id sourcePatchSetId = sourceChange == null ? null : sourceChange.currentPatchSetId();
+    // If sourceChange is not provided, reset cherryPickOf to avoid stale value.
+    if (sourcePatchSetId == null) {
+      bu.addOp(destChange.getId(), new ResetCherryPickOp());
+    } else if (destChange.getCherryPickOf() == null
         || !destChange.getCherryPickOf().equals(sourcePatchSetId)) {
       SetCherryPickOp cherryPickOfUpdater = setCherryPickOfFactory.create(sourcePatchSetId);
       bu.addOp(destChange.getId(), cherryPickOfUpdater);
@@ -571,4 +555,81 @@
 
     return stringBuilder.toString();
   }
+
+  /**
+   * Returns the Change-Id of destination change (as intended by the caller of cherry-pick
+   * operation).
+   *
+   * <p>The Change-Id can be provided in one of the following ways:
+   *
+   * <ul>
+   *   <li>Explicitly provided for the new change.
+   *   <li>Provided in the input commit message.
+   *   <li>Taken from the source commit if commit message was not set.
+   * </ul>
+   *
+   * Otherwise should be generated.
+   *
+   * @param commitMessage the commit message, as intended by the caller of cherry-pick operation.
+   * @param changeIdForNewChange the explicitly provided Change-Id for the new change.
+   * @return The Change-Id of destination change, {@code null} if Change-Id was not provided by the
+   *     caller of cherry-pick operation and should be generated.
+   */
+  @Nullable
+  private String getDestinationChangeId(
+      String commitMessage, @Nullable ObjectId changeIdForNewChange) {
+    if (changeIdForNewChange != null) {
+      return CommitMessageUtil.getChangeIdFromObjectId(changeIdForNewChange);
+    }
+    return CommitMessageUtil.getChangeIdFromCommitMessageFooter(commitMessage).orElse(null);
+  }
+
+  /**
+   * Returns the change from the destination branch, if it exists and is valid for the cherry-pick.
+   *
+   * @param destChangeId the Change-ID of the change in the destination branch.
+   * @param destBranch the branch to search by the Change-ID.
+   * @param verifyIsMissing if {@code true}, verifies that the change should be missing in the
+   *     destination branch.
+   * @return the verified change or {@code null} if the change was not found.
+   * @throws InvalidChangeOperationException if the change was found but failed validation
+   */
+  @Nullable
+  private ChangeData getDestChangeWithVerification(
+      String destChangeId, BranchNameKey destBranch, boolean verifyIsMissing)
+      throws InvalidChangeOperationException {
+    List<ChangeData> destChanges =
+        queryProvider.get().setLimit(2).byBranchKey(destBranch, Change.key(destChangeId));
+    if (destChanges.size() > 1) {
+      throw new InvalidChangeOperationException(
+          "Several changes with key "
+              + destChangeId
+              + " reside on the same branch. "
+              + "Cannot create a new patch set.");
+    }
+    if (destChanges.size() == 1 && verifyIsMissing) {
+      throw new InvalidChangeOperationException(
+          String.format(
+              "Expected that cherry-pick with Change-Id %s to branch %s "
+                  + "in project %s creates a new change, but found existing change %d",
+              destChangeId,
+              destBranch.branch(),
+              destBranch.project().get(),
+              destChanges.get(0).getId().get()));
+    }
+    ChangeData destChange = destChanges.size() == 1 ? destChanges.get(0) : null;
+
+    if (destChange != null && destChange.change().isClosed()) {
+      throw new InvalidChangeOperationException(
+          String.format(
+              "Cherry-pick with Change-Id %s could not update the existing change %d "
+                  + "in destination branch %s of project %s, because the change was closed (%s)",
+              destChangeId,
+              destChange.getId().get(),
+              destBranch.branch(),
+              destBranch.project(),
+              destChange.change().getStatus().name()));
+    }
+    return destChange;
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/CommentJson.java b/java/com/google/gerrit/server/restapi/change/CommentJson.java
index 4de9b63..77b58c6 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentJson.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentJson.java
@@ -20,9 +20,12 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Streams;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.CommentContext;
 import com.google.gerrit.entities.FixReplacement;
 import com.google.gerrit.entities.FixSuggestion;
 import com.google.gerrit.entities.HumanComment;
@@ -31,15 +34,18 @@
 import com.google.gerrit.extensions.client.Comment.Range;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.ContextLineInfo;
 import com.google.gerrit.extensions.common.FixReplacementInfo;
 import com.google.gerrit.extensions.common.FixSuggestionInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.server.CommentContextLoader;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.comment.CommentContextCache;
+import com.google.gerrit.server.comment.CommentContextKey;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.List;
 import java.util.Map;
 import java.util.TreeMap;
@@ -47,18 +53,20 @@
 public class CommentJson {
 
   private final AccountLoader.Factory accountLoaderFactory;
+  private final CommentContextCache commentContextCache;
+
+  private Project.NameKey project;
+  private Change.Id changeId;
 
   private boolean fillAccounts = true;
   private boolean fillPatchSet;
-  private CommentContextLoader.Factory commentContextLoaderFactory;
-  private CommentContextLoader commentContextLoader;
+  private boolean fillCommentContext;
+  private int contextPadding;
 
   @Inject
-  CommentJson(
-      AccountLoader.Factory accountLoaderFactory,
-      CommentContextLoader.Factory commentContextLoaderFactory) {
+  CommentJson(AccountLoader.Factory accountLoaderFactory, CommentContextCache commentContextCache) {
     this.accountLoaderFactory = accountLoaderFactory;
-    this.commentContextLoaderFactory = commentContextLoaderFactory;
+    this.commentContextCache = commentContextCache;
   }
 
   CommentJson setFillAccounts(boolean fillAccounts) {
@@ -71,10 +79,23 @@
     return this;
   }
 
-  CommentJson setEnableContext(boolean enableContext, Project.NameKey project) {
-    if (enableContext) {
-      this.commentContextLoader = commentContextLoaderFactory.create(project);
-    }
+  CommentJson setFillCommentContext(boolean fillCommentContext) {
+    this.fillCommentContext = fillCommentContext;
+    return this;
+  }
+
+  CommentJson setContextPadding(int contextPadding) {
+    this.contextPadding = contextPadding;
+    return this;
+  }
+
+  CommentJson setProjectKey(Project.NameKey project) {
+    this.project = project;
+    return this;
+  }
+
+  CommentJson setChangeId(Change.Id changeId) {
+    this.changeId = changeId;
     return this;
   }
 
@@ -93,9 +114,6 @@
       if (loader != null) {
         loader.fill();
       }
-      if (commentContextLoader != null) {
-        commentContextLoader.fill();
-      }
       return info;
     }
 
@@ -111,7 +129,6 @@
           list = new ArrayList<>();
           out.put(o.path, list);
         }
-        o.path = null;
         list.add(o);
       }
 
@@ -120,9 +137,12 @@
       if (loader != null) {
         loader.fill();
       }
-      if (commentContextLoader != null) {
-        commentContextLoader.fill();
+
+      List<T> allComments = out.values().stream().flatMap(Collection::stream).collect(toList());
+      if (fillCommentContext) {
+        addCommentContext(allComments);
       }
+      allComments.forEach(c -> c.path = null); // we don't need path since it exists in the map keys
       return out;
     }
 
@@ -138,12 +158,42 @@
       if (loader != null) {
         loader.fill();
       }
-      if (commentContextLoader != null) {
-        commentContextLoader.fill();
+
+      if (fillCommentContext) {
+        addCommentContext(out);
       }
+
       return out;
     }
 
+    protected void addCommentContext(List<T> allComments) {
+      List<CommentContextKey> keys =
+          allComments.stream().map(this::createCommentContextKey).collect(toList());
+      ImmutableMap<CommentContextKey, CommentContext> allContext = commentContextCache.getAll(keys);
+      for (T c : allComments) {
+        c.contextLines = toContextLineInfoList(allContext.get(createCommentContextKey(c)));
+      }
+    }
+
+    protected List<ContextLineInfo> toContextLineInfoList(CommentContext commentContext) {
+      List<ContextLineInfo> result = new ArrayList<>();
+      for (Map.Entry<Integer, String> e : commentContext.lines().entrySet()) {
+        result.add(new ContextLineInfo(e.getKey(), e.getValue()));
+      }
+      return result;
+    }
+
+    protected CommentContextKey createCommentContextKey(T r) {
+      return CommentContextKey.builder()
+          .project(project)
+          .changeId(changeId)
+          .id(r.id)
+          .path(r.path)
+          .patchset(r.patchSet)
+          .contextPadding(contextPadding)
+          .build();
+    }
+
     protected abstract T toInfo(F comment, AccountLoader loader);
 
     protected void fillCommentInfo(Comment c, CommentInfo r, AccountLoader loader) {
@@ -170,9 +220,6 @@
         r.author = loader.get(c.author.getId());
       }
       r.commitId = c.getCommitId().getName();
-      if (commentContextLoader != null) {
-        r.contextLines = commentContextLoader.getContext(r);
-      }
     }
 
     protected Range toRange(Comment.Range commentRange) {
diff --git a/java/com/google/gerrit/server/restapi/change/CommentPorter.java b/java/com/google/gerrit/server/restapi/change/CommentPorter.java
index 681509c..34af285 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentPorter.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentPorter.java
@@ -18,7 +18,9 @@
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static java.util.stream.Collectors.groupingBy;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
@@ -28,6 +30,9 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.DiffMappings;
@@ -47,6 +52,7 @@
 import java.util.Map;
 import java.util.Map.Entry;
 import java.util.Optional;
+import java.util.function.Function;
 import java.util.stream.Stream;
 import org.eclipse.jgit.lib.ObjectId;
 
@@ -62,15 +68,48 @@
 public class CommentPorter {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  @VisibleForTesting
+  @Singleton
+  static class Metrics {
+    final Counter0 portedAsPatchsetLevel;
+    final Counter0 portedAsFileLevel;
+    final Counter0 portedAsRangeComments;
+
+    @Inject
+    Metrics(MetricMaker metricMaker) {
+      portedAsPatchsetLevel =
+          metricMaker.newCounter(
+              "ported_comments/as_patchset_level",
+              new Description("Total number of comments ported as patchset-level comments.")
+                  .setRate()
+                  .setUnit("count"));
+      portedAsFileLevel =
+          metricMaker.newCounter(
+              "ported_comments/as_file_level",
+              new Description("Total number of comments ported as file-level comments.")
+                  .setRate()
+                  .setUnit("count"));
+      portedAsRangeComments =
+          metricMaker.newCounter(
+              "ported_comments/as_range_comments",
+              new Description(
+                      "Total number of comments having line/range values in the ported patchset.")
+                  .setRate()
+                  .setUnit("count"));
+    }
+  }
+
   private final GitPositionTransformer positionTransformer =
       new GitPositionTransformer(BestPositionOnConflict.INSTANCE);
   private final PatchListCache patchListCache;
   private final CommentsUtil commentsUtil;
+  private final Metrics metrics;
 
   @Inject
-  public CommentPorter(PatchListCache patchListCache, CommentsUtil commentsUtil) {
+  public CommentPorter(PatchListCache patchListCache, CommentsUtil commentsUtil, Metrics metrics) {
     this.patchListCache = patchListCache;
     this.commentsUtil = commentsUtil;
+    this.metrics = metrics;
   }
 
   /**
@@ -204,9 +243,13 @@
 
     ImmutableList<PositionedEntity<HumanComment>> positionedComments =
         comments.stream().map(this::toPositionedEntity).collect(toImmutableList());
-    return positionTransformer.transform(positionedComments, mappings).stream()
-        .map(PositionedEntity::getEntityAtUpdatedPosition)
-        .collect(toImmutableList());
+    ImmutableMap<PositionedEntity<HumanComment>, HumanComment> origToPortedMap =
+        positionTransformer.transform(positionedComments, mappings).stream()
+            .collect(
+                ImmutableMap.toImmutableMap(
+                    Function.identity(), PositionedEntity::getEntityAtUpdatedPosition));
+    collectMetrics(origToPortedMap);
+    return ImmutableList.copyOf(origToPortedMap.values());
   }
 
   private ImmutableSet<Mapping> loadMappings(
@@ -269,6 +312,10 @@
     return positionBuilder.lineRange(extractLineRange(comment)).build();
   }
 
+  /**
+   * Returns {@link Optional#empty()} if the {@code comment} parameter is a file comment, or the
+   * comment range {start_line, end_line} otherwise.
+   */
   private static Optional<GitPositionTransformer.Range> extractLineRange(HumanComment comment) {
     // Line specifications in comment are 1-based. Line specifications in Position are 0-based.
     if (comment.range != null) {
@@ -316,6 +363,33 @@
     return new Range(lineRange.start() + 1, originalStartChar, adjustedEndLine, originalEndChar);
   }
 
+  /**
+   * Collect metrics from the original and ported comments.
+   *
+   * @param portMap map of the ported comments. The keys contain a {@link PositionedEntity} of the
+   *     original comment, and the values contain the transformed comments.
+   */
+  private void collectMetrics(ImmutableMap<PositionedEntity<HumanComment>, HumanComment> portMap) {
+    for (Map.Entry<PositionedEntity<HumanComment>, HumanComment> entry : portMap.entrySet()) {
+      HumanComment original = entry.getKey().getEntity();
+      HumanComment transformed = entry.getValue();
+
+      if (!Patch.isMagic(original.key.filename)) {
+        if (Patch.PATCHSET_LEVEL.equals(transformed.key.filename)) {
+          metrics.portedAsPatchsetLevel.increment();
+        } else if (extractLineRange(original).isPresent()) {
+          if (extractLineRange(transformed).isPresent()) {
+            metrics.portedAsRangeComments.increment();
+          } else {
+            // line range was present in the original comment, but the ported comment is a file
+            // level comment.
+            metrics.portedAsFileLevel.increment();
+          }
+        }
+      }
+    }
+  }
+
   /** A filter which just keeps those comments which are before the given patchset. */
   private static class EarlierPatchsetCommentFilter implements HumanCommentFilter {
 
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index 52887e0..c392bd1 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -223,6 +223,12 @@
       throw new BadRequestException("branch must be non-empty");
     }
     input.branch = RefNames.fullName(input.branch);
+    if (!isBranchAllowed(input.branch)) {
+      throw new BadRequestException(
+          "Cannot create a change on ref "
+              + input.branch
+              + ". Gerrit internal refs and refs/tags/* are not allowed.");
+    }
 
     String subject = Strings.nullToEmpty(input.subject);
     subject = subject.replaceAll("(?m)^#.*$\n?", "").trim();
@@ -292,6 +298,11 @@
     }
   }
 
+  /** Changes are allowed to be created on any ref that is not Gerrit internal or a tag ref. */
+  private boolean isBranchAllowed(String branch) {
+    return !RefNames.isGerritRef(branch) && !branch.startsWith(RefNames.REFS_TAGS);
+  }
+
   private void checkRequiredPermissions(
       Project.NameKey project, String refName, @Nullable AccountInput author)
       throws ResourceNotFoundException, AuthException, PermissionBackendException {
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java b/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
index 20fd675..842ed2a 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
@@ -93,10 +93,7 @@
       }
       IdentifiedUser deletedAssigneeUser = userFactory.create(currentAssigneeId);
       deletedAssignee = deletedAssigneeUser.state();
-      // noteDb
       update.removeAssignee();
-      // reviewDb
-      change.setAssignee(null);
       addMessage(ctx, update, deletedAssigneeUser);
       return true;
     }
diff --git a/java/com/google/gerrit/server/restapi/change/Files.java b/java/com/google/gerrit/server/restapi/change/Files.java
index 392aef7..f82284e 100644
--- a/java/com/google/gerrit/server/restapi/change/Files.java
+++ b/java/com/google/gerrit/server/restapi/change/Files.java
@@ -163,7 +163,7 @@
             revisions.parse(resource.getChangeResource(), IdString.fromDecoded(base));
         r =
             Response.ok(
-                fileInfoJson.toFileInfoMap(
+                fileInfoJson.getFileInfoMap(
                     resource.getChange(),
                     resource.getPatchSet().commitId(),
                     baseResource.getPatchSet()));
@@ -180,10 +180,10 @@
         }
         r =
             Response.ok(
-                fileInfoJson.toFileInfoMap(
+                fileInfoJson.getFileInfoMap(
                     resource.getChange(), resource.getPatchSet().commitId(), parentNum - 1));
       } else {
-        r = Response.ok(fileInfoJson.toFileInfoMap(resource.getChange(), resource.getPatchSet()));
+        r = Response.ok(fileInfoJson.getFileInfoMap(resource.getChange(), resource.getPatchSet()));
       }
 
       if (resource.isCacheable()) {
diff --git a/java/com/google/gerrit/server/restapi/change/GetChange.java b/java/com/google/gerrit/server/restapi/change/GetChange.java
index 1ef3c4b..2f1c61e 100644
--- a/java/com/google/gerrit/server/restapi/change/GetChange.java
+++ b/java/com/google/gerrit/server/restapi/change/GetChange.java
@@ -14,31 +14,35 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Streams;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ListOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.PreconditionFailedException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.DynamicOptions.DynamicBean;
-import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.PluginDefinedAttributesFactories;
 import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.notedb.MissingMetaObjectException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import java.util.Collection;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Map;
+import org.eclipse.jgit.errors.InvalidObjectIdException;
+import org.eclipse.jgit.lib.ObjectId;
 import org.kohsuke.args4j.Option;
 
 public class GetChange
@@ -46,7 +50,6 @@
         DynamicOptions.BeanReceiver,
         DynamicOptions.BeanProvider {
   private final ChangeJson.Factory json;
-  private final DynamicSet<ChangeAttributeFactory> attrFactories;
   private final DynamicSet<ChangePluginDefinedInfoFactory> pdiFactories;
   private final EnumSet<ListChangesOption> options = EnumSet.noneOf(ListChangesOption.class);
   private final Map<String, DynamicBean> dynamicBeans = new HashMap<>();
@@ -56,18 +59,17 @@
     options.add(o);
   }
 
+  @Option(name = "--meta", usage = "NoteDb meta SHA1")
+  String metaRevId = "";
+
   @Option(name = "-O", usage = "Output option flags, in hex")
   void setOptionFlagsHex(String hex) {
     options.addAll(ListOption.fromBits(ListChangesOption.class, Integer.parseInt(hex, 16)));
   }
 
   @Inject
-  GetChange(
-      ChangeJson.Factory json,
-      DynamicSet<ChangeAttributeFactory> attrFactories,
-      DynamicSet<ChangePluginDefinedInfoFactory> pdiFactories) {
+  GetChange(ChangeJson.Factory json, DynamicSet<ChangePluginDefinedInfoFactory> pdiFactories) {
     this.json = json;
-    this.attrFactories = attrFactories;
     this.pdiFactories = pdiFactories;
   }
 
@@ -82,21 +84,37 @@
   }
 
   @Override
-  public Response<ChangeInfo> apply(ChangeResource rsrc) {
-    return Response.withMustRevalidate(newChangeJson().format(rsrc));
+  public Response<ChangeInfo> apply(ChangeResource rsrc)
+      throws BadRequestException, PreconditionFailedException {
+    try {
+      return Response.withMustRevalidate(newChangeJson().format(rsrc.getChange(), getMetaRevId()));
+    } catch (MissingMetaObjectException e) {
+      throw new PreconditionFailedException(e.getMessage());
+    }
   }
 
   Response<ChangeInfo> apply(RevisionResource rsrc) {
     return Response.withMustRevalidate(newChangeJson().format(rsrc));
   }
 
-  private ChangeJson newChangeJson() {
-    return json.create(options, this::buildPluginInfo, this::createPluginDefinedInfos);
+  @Nullable
+  private ObjectId getMetaRevId() throws BadRequestException {
+    if (metaRevId.isEmpty()) {
+      return null;
+    }
+
+    // It might be interesting to also allow {SHA1}^^, so callers can walk back into history
+    // without having to fetch the entire /meta ref. If we do so, we have to be careful that
+    // the error messages can't be abused to fetch hidden data.
+    try {
+      return ObjectId.fromString(metaRevId);
+    } catch (InvalidObjectIdException e) {
+      throw new BadRequestException("invalid meta SHA1: " + metaRevId, e);
+    }
   }
 
-  private ImmutableList<PluginDefinedInfo> buildPluginInfo(ChangeData cd) {
-    return PluginDefinedAttributesFactories.createAll(
-        cd, this, Streams.stream(attrFactories.entries()));
+  private ChangeJson newChangeJson() {
+    return json.create(options, this::createPluginDefinedInfos);
   }
 
   private ImmutableListMultimap<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
diff --git a/java/com/google/gerrit/server/restapi/change/GetCommit.java b/java/com/google/gerrit/server/restapi/change/GetCommit.java
index 21a08dc..d76ce04 100644
--- a/java/com/google/gerrit/server/restapi/change/GetCommit.java
+++ b/java/com/google/gerrit/server/restapi/change/GetCommit.java
@@ -58,7 +58,13 @@
       rw.parseBody(commit);
       CommitInfo info =
           json.create(ImmutableSet.of())
-              .getCommitInfo(rsrc.getProject(), rw, commit, addLinks, true);
+              .getCommitInfo(
+                  rsrc.getProject(),
+                  rw,
+                  commit,
+                  addLinks,
+                  /* fillCommit= */ true,
+                  rsrc.getChange().getDest().branch());
       Response<CommitInfo> r = Response.ok(info);
       if (rsrc.isCacheable()) {
         r.caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
diff --git a/java/com/google/gerrit/server/restapi/change/GetDetail.java b/java/com/google/gerrit/server/restapi/change/GetDetail.java
index e31d84b..15362d5 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDetail.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDetail.java
@@ -16,6 +16,8 @@
 
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.PreconditionFailedException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.DynamicOptions;
@@ -58,7 +60,8 @@
   }
 
   @Override
-  public Response<ChangeInfo> apply(ChangeResource rsrc) {
+  public Response<ChangeInfo> apply(ChangeResource rsrc)
+      throws BadRequestException, PreconditionFailedException {
     return delegate.apply(rsrc);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetDiff.java b/java/com/google/gerrit/server/restapi/change/GetDiff.java
index 8d51786..b8902b7 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDiff.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDiff.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.WebLinks;
 import com.google.gerrit.server.change.FileResource;
 import com.google.gerrit.server.change.RevisionResource;
@@ -51,6 +52,7 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import java.io.IOException;
 import java.util.concurrent.TimeUnit;
 import org.kohsuke.args4j.CmdLineParser;
@@ -67,6 +69,7 @@
   private final PatchScriptFactory.Factory patchScriptFactoryFactory;
   private final Revisions revisions;
   private final WebLinks webLinks;
+  private final Provider<CurrentUser> currentUser;
 
   @Option(name = "--base", metaVar = "REVISION")
   String base;
@@ -93,11 +96,13 @@
       ProjectCache projectCache,
       PatchScriptFactory.Factory patchScriptFactoryFactory,
       Revisions revisions,
-      WebLinks webLinks) {
+      WebLinks webLinks,
+      Provider<CurrentUser> currentUser) {
     this.projectCache = projectCache;
     this.patchScriptFactoryFactory = patchScriptFactoryFactory;
     this.revisions = revisions;
     this.webLinks = webLinks;
+    this.currentUser = currentUser;
   }
 
   @Override
@@ -132,11 +137,15 @@
       if (basePatchSet.id().get() == 0) {
         throw new BadRequestException("edit not allowed as base");
       }
-      psf = patchScriptFactoryFactory.create(notes, fileName, basePatchSet.id(), pId, prefs);
+      psf =
+          patchScriptFactoryFactory.create(
+              notes, fileName, basePatchSet.id(), pId, prefs, currentUser.get());
     } else if (parentNum > 0) {
-      psf = patchScriptFactoryFactory.create(notes, fileName, parentNum - 1, pId, prefs);
+      psf =
+          patchScriptFactoryFactory.create(
+              notes, fileName, parentNum - 1, pId, prefs, currentUser.get());
     } else {
-      psf = patchScriptFactoryFactory.create(notes, fileName, null, pId, prefs);
+      psf = patchScriptFactoryFactory.create(notes, fileName, null, pId, prefs, currentUser.get());
     }
 
     try {
diff --git a/java/com/google/gerrit/server/restapi/change/GetMergeList.java b/java/com/google/gerrit/server/restapi/change/GetMergeList.java
index 0c67fd6..f0639b5 100644
--- a/java/com/google/gerrit/server/restapi/change/GetMergeList.java
+++ b/java/com/google/gerrit/server/restapi/change/GetMergeList.java
@@ -81,7 +81,14 @@
       List<CommitInfo> result = new ArrayList<>(commits.size());
       RevisionJson changeJson = json.create(ImmutableSet.of());
       for (RevCommit c : commits) {
-        result.add(changeJson.getCommitInfo(rsrc.getProject(), rw, c, addLinks, true));
+        result.add(
+            changeJson.getCommitInfo(
+                rsrc.getProject(),
+                rw,
+                c,
+                addLinks,
+                /* fillCommit= */ true,
+                rsrc.getChange().getDest().branch()));
       }
       return createResponse(rsrc, result);
     }
diff --git a/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java b/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java
index c4da3b6..527129c 100644
--- a/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java
+++ b/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java
@@ -14,67 +14,26 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.common.hash.Hasher;
-import com.google.common.hash.Hashing;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.common.ActionInfo;
-import com.google.gerrit.extensions.restapi.ETagView;
 import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.ActionJson;
-import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.submit.ChangeSet;
-import com.google.gerrit.server.submit.MergeSuperSet;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
 import java.util.Map;
-import org.eclipse.jgit.lib.Config;
 
 @Singleton
-public class GetRevisionActions implements ETagView<RevisionResource> {
+public class GetRevisionActions implements RestReadView<RevisionResource> {
   private final ActionJson delegate;
-  private final Config config;
-  private final Provider<MergeSuperSet> mergeSuperSet;
-  private final ChangeResource.Factory changeResourceFactory;
 
   @Inject
-  GetRevisionActions(
-      ActionJson delegate,
-      Provider<MergeSuperSet> mergeSuperSet,
-      ChangeResource.Factory changeResourceFactory,
-      @GerritServerConfig Config config) {
+  GetRevisionActions(ActionJson delegate) {
     this.delegate = delegate;
-    this.mergeSuperSet = mergeSuperSet;
-    this.changeResourceFactory = changeResourceFactory;
-    this.config = config;
   }
 
   @Override
   public Response<Map<String, ActionInfo>> apply(RevisionResource rsrc) {
     return Response.withMustRevalidate(delegate.format(rsrc));
   }
-
-  @Override
-  public String getETag(RevisionResource rsrc) {
-    Hasher h = Hashing.murmur3_128().newHasher();
-    CurrentUser user = rsrc.getUser();
-    try {
-      rsrc.getChangeResource().prepareETag(h, user);
-      h.putBoolean(MergeSuperSet.wholeTopicEnabled(config));
-      ChangeSet cs = mergeSuperSet.get().completeChangeSet(rsrc.getChange(), user);
-      for (ChangeData cd : cs.changes()) {
-        changeResourceFactory.create(cd.notes(), user).prepareETag(h, user);
-      }
-      h.putBoolean(cs.furtherHiddenChanges());
-    } catch (IOException | PermissionBackendException e) {
-      throw new StorageException(e);
-    }
-    return h.hash().toString();
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
index e3b433c..c90e4fc 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
@@ -19,7 +19,6 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.HumanComment;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.ContextLineInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -43,6 +42,7 @@
   private final CommentsUtil commentsUtil;
 
   private boolean includeContext;
+  private int contextPadding;
 
   /**
    * Optional parameter. If set, the contextLines field of the {@link ContextLineInfo} of the
@@ -55,6 +55,16 @@
     this.includeContext = context;
   }
 
+  /**
+   * Optional parameter. Works only if {@link #includeContext} is set to true. If {@link
+   * #contextPadding} is set, the context lines in the response will be padded with {@link
+   * #contextPadding} extra lines before and after the comment range.
+   */
+  @Option(name = "--context-padding")
+  public void setContextPadding(int contextPadding) {
+    this.contextPadding = contextPadding;
+  }
+
   @Inject
   ListChangeComments(
       ChangeData.Factory changeDataFactory,
@@ -84,8 +94,7 @@
 
   private ImmutableList<CommentInfo> getAsList(Iterable<HumanComment> comments, ChangeResource rsrc)
       throws PermissionBackendException {
-    ImmutableList<CommentInfo> commentInfos =
-        getCommentFormatter(rsrc.getProject()).formatAsList(comments);
+    ImmutableList<CommentInfo> commentInfos = getCommentFormatter(rsrc).formatAsList(comments);
     List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
     CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages, true);
     return commentInfos;
@@ -93,8 +102,7 @@
 
   private Map<String, List<CommentInfo>> getAsMap(
       Iterable<HumanComment> comments, ChangeResource rsrc) throws PermissionBackendException {
-    Map<String, List<CommentInfo>> commentInfosMap =
-        getCommentFormatter(rsrc.getProject()).format(comments);
+    Map<String, List<CommentInfo>> commentInfosMap = getCommentFormatter(rsrc).format(comments);
     List<CommentInfo> commentInfos =
         commentInfosMap.values().stream().flatMap(List::stream).collect(toList());
     List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
@@ -102,12 +110,15 @@
     return commentInfosMap;
   }
 
-  private CommentJson.HumanCommentFormatter getCommentFormatter(Project.NameKey project) {
+  private CommentJson.HumanCommentFormatter getCommentFormatter(ChangeResource rsrc) {
     return commentJson
         .get()
         .setFillAccounts(true)
         .setFillPatchSet(true)
-        .setEnableContext(includeContext, project)
+        .setFillCommentContext(includeContext)
+        .setContextPadding(contextPadding)
+        .setProjectKey(rsrc.getProject())
+        .setChangeId(rsrc.getId())
         .newHumanCommentFormatter();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListPortedDrafts.java b/java/com/google/gerrit/server/restapi/change/ListPortedDrafts.java
index 9b254f1..e92fe5c 100644
--- a/java/com/google/gerrit/server/restapi/change/ListPortedDrafts.java
+++ b/java/com/google/gerrit/server/restapi/change/ListPortedDrafts.java
@@ -18,7 +18,9 @@
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.RevisionResource;
@@ -46,7 +48,10 @@
 
   @Override
   public Response<Map<String, List<CommentInfo>>> apply(RevisionResource revisionResource)
-      throws PermissionBackendException {
+      throws PermissionBackendException, RestApiException {
+    if (!revisionResource.getUser().isIdentifiedUser()) {
+      throw new AuthException("requires authentication; only authenticated users can have drafts");
+    }
     PatchSet targetPatchset = revisionResource.getPatchSet();
 
     List<HumanComment> draftComments =
diff --git a/java/com/google/gerrit/server/restapi/change/Module.java b/java/com/google/gerrit/server/restapi/change/Module.java
index 69e2788..28f4114 100644
--- a/java/com/google/gerrit/server/restapi/change/Module.java
+++ b/java/com/google/gerrit/server/restapi/change/Module.java
@@ -29,7 +29,6 @@
 
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestApiModule;
-import com.google.gerrit.server.CommentContextLoader;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.change.AddReviewersOp;
 import com.google.gerrit.server.change.AddToAttentionSetOp;
@@ -47,7 +46,9 @@
 import com.google.gerrit.server.change.SetCherryPickOp;
 import com.google.gerrit.server.change.SetHashtagsOp;
 import com.google.gerrit.server.change.SetPrivateOp;
+import com.google.gerrit.server.change.SetTopicOp;
 import com.google.gerrit.server.change.WorkInProgressOp;
+import com.google.gerrit.server.comment.CommentContextLoader;
 import com.google.gerrit.server.restapi.change.Reviewed.DeleteReviewed;
 import com.google.gerrit.server.restapi.change.Reviewed.PutReviewed;
 import com.google.gerrit.server.util.AttentionSetEmail;
@@ -218,9 +219,9 @@
     factory(SetAssigneeOp.Factory.class);
     factory(SetCherryPickOp.Factory.class);
     factory(SetHashtagsOp.Factory.class);
+    factory(SetTopicOp.Factory.class);
     factory(SetPrivateOp.Factory.class);
     factory(WorkInProgressOp.Factory.class);
-    factory(SetTopicOp.Factory.class);
     factory(AddToAttentionSetOp.Factory.class);
     factory(RemoveFromAttentionSetOp.Factory.class);
     factory(AttentionSetEmail.Factory.class);
diff --git a/java/com/google/gerrit/server/restapi/change/Move.java b/java/com/google/gerrit/server/restapi/change/Move.java
index 577174f..8ec394c 100644
--- a/java/com/google/gerrit/server/restapi/change/Move.java
+++ b/java/com/google/gerrit/server/restapi/change/Move.java
@@ -52,6 +52,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
@@ -140,6 +141,18 @@
     // Not allowed to move if the current patch set is locked.
     psUtil.checkPatchSetNotLocked(rsrc.getNotes());
 
+    // Keeping all votes can be confusing in the context of the destination branch, see the
+    // discussion in
+    // https://gerrit-review.googlesource.com/c/gerrit/+/129171
+    // Only administrators are allowed to keep all labels at their own risk.
+    try {
+      if (input.keepAllVotes) {
+        permissionBackend.user(caller).check(GlobalPermission.ADMINISTRATE_SERVER);
+      }
+    } catch (AuthException denied) {
+      throw new AuthException("move is not permitted with keepAllVotes option", denied);
+    }
+
     // Move requires abandoning this change, and creating a new change.
     try {
       rsrc.permissions().check(ABANDON);
@@ -226,7 +239,9 @@
       update.setBranch(newDestKey.branch());
       change.setDest(newDestKey);
 
-      updateApprovals(ctx, update, psId, projectKey);
+      if (!input.keepAllVotes) {
+        updateApprovals(ctx, update, psId, projectKey);
+      }
 
       StringBuilder msgBuf = new StringBuilder();
       msgBuf.append("Change destination moved from ");
diff --git a/java/com/google/gerrit/server/restapi/change/OnPostReview.java b/java/com/google/gerrit/server/restapi/change/OnPostReview.java
new file mode 100644
index 0000000..b179d02
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/OnPostReview.java
@@ -0,0 +1,47 @@
+// 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.restapi.change;
+
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import java.util.Map;
+import java.util.Optional;
+
+/** Extension point that is invoked on post review. */
+@ExtensionPoint
+public interface OnPostReview {
+  /**
+   * Allows implementors to return a message that should be included into the change message that is
+   * posted on post review.
+   *
+   * @param user the user that posts the review
+   * @param changeNotes the change on which post review is performed
+   * @param patchSet the patch set on which post review is performed
+   * @param oldApprovals old approvals that changed as result of post review
+   * @param approvals all current approvals
+   * @return message that should be included into the change message that is posted on post review,
+   *     {@link Optional#empty()} if the change message should not be extended
+   */
+  default Optional<String> getChangeMessageAddOn(
+      IdentifiedUser user,
+      ChangeNotes changeNotes,
+      PatchSet patchSet,
+      Map<String, Short> oldApprovals,
+      Map<String, Short> approvals) {
+    return Optional.empty();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 575a19d..73b38b2 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
@@ -30,6 +29,7 @@
 import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
@@ -177,6 +177,7 @@
   private final ProjectCache projectCache;
   private final PermissionBackend permissionBackend;
   private final PluginSetContext<CommentValidator> commentValidators;
+  private final PluginSetContext<OnPostReview> onPostReviews;
   private final ReplyAttentionSetUpdates replyAttentionSetUpdates;
   private final boolean strictLabels;
   private final boolean publishPatchSetLevelComment;
@@ -203,6 +204,7 @@
       ProjectCache projectCache,
       PermissionBackend permissionBackend,
       PluginSetContext<CommentValidator> commentValidators,
+      PluginSetContext<OnPostReview> onPostReviews,
       ReplyAttentionSetUpdates replyAttentionSetUpdates) {
     this.updateFactory = updateFactory;
     this.changeResourceFactory = changeResourceFactory;
@@ -223,6 +225,7 @@
     this.projectCache = projectCache;
     this.permissionBackend = permissionBackend;
     this.commentValidators = commentValidators;
+    this.onPostReviews = onPostReviews;
     this.replyAttentionSetUpdates = replyAttentionSetUpdates;
     this.strictLabels = gerritConfig.getBoolean("change", "strictLabels", false);
     this.publishPatchSetLevelComment =
@@ -1225,9 +1228,10 @@
     }
 
     private boolean isReviewer(ChangeContext ctx) {
-      ChangeData cd = changeDataFactory.create(ctx.getNotes());
-      ReviewerSet reviewers = cd.reviewers();
-      return reviewers.byState(REVIEWER).contains(ctx.getAccountId());
+      return approvalsUtil
+          .getReviewers(ctx.getNotes())
+          .byState(REVIEWER)
+          .contains(ctx.getAccountId());
     }
 
     private boolean updateLabels(ProjectState projectState, ChangeContext ctx)
@@ -1269,7 +1273,10 @@
             del.add(c);
             update.putApproval(normName, (short) 0);
           }
-        } else if (c != null && c.value() != ent.getValue()) {
+          // Only allow voting again if the vote is copied over from a past patch-set, or the
+          // values are different.
+        } else if (c != null
+            && (c.value() != ent.getValue() || isApprovalCopiedOver(c, ctx.getNotes()))) {
           PatchSetApproval.Builder b =
               c.toBuilder()
                   .value(ent.getValue())
@@ -1312,6 +1319,17 @@
       return !del.isEmpty() || !ups.isEmpty();
     }
 
+    /**
+     * Approval is copied over if it doesn't exist in the approvals of the current patch-set
+     * according to change notes (which means it was computed in {@link
+     * com.google.gerrit.server.ApprovalInference})
+     */
+    private boolean isApprovalCopiedOver(
+        PatchSetApproval patchSetApproval, ChangeNotes changeNotes) {
+      return !changeNotes.getApprovals().get(changeNotes.getChange().currentPatchSetId()).stream()
+          .anyMatch(p -> p.equals(patchSetApproval));
+    }
+
     private void validatePostSubmitLabels(
         ChangeContext ctx,
         LabelTypes labelTypes,
@@ -1356,7 +1374,6 @@
         if (prev == null) {
           continue;
         }
-        checkState(prev != psa.value()); // Should be filtered out above.
         if (prev > psa.value()) {
           reduced.add(psa);
         }
@@ -1425,6 +1442,23 @@
       } else if (in.ready) {
         buf.append("\n\n" + START_REVIEW_MESSAGE);
       }
+
+      List<String> pluginMessages = new ArrayList<>();
+      onPostReviews.runEach(
+          onPostReview ->
+              onPostReview
+                  .getChangeMessageAddOn(user, ctx.getNotes(), ps, oldApprovals, approvals)
+                  .ifPresent(
+                      pluginMessage ->
+                          pluginMessages.add(
+                              !pluginMessage.endsWith("\n")
+                                  ? pluginMessage + "\n"
+                                  : pluginMessage)));
+      if (!pluginMessages.isEmpty()) {
+        buf.append("\n\n");
+        buf.append(Joiner.on("\n").join(pluginMessages));
+      }
+
       if (buf.length() == 0) {
         return false;
       }
diff --git a/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java b/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java
index ed6c0a5..4acf809 100644
--- a/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java
+++ b/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java
@@ -42,7 +42,6 @@
 import com.google.gerrit.server.update.UpdateException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.Singleton;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.util.Collection;
@@ -56,7 +55,6 @@
 import org.eclipse.jgit.transport.ReceiveCommand;
 import org.kohsuke.args4j.Option;
 
-@Singleton
 public class PreviewSubmit implements RestReadView<RevisionResource> {
   private static final int MAX_DEFAULT_BUNDLE_SIZE = 100 * 1024 * 1024;
 
diff --git a/java/com/google/gerrit/server/restapi/change/PutMessage.java b/java/com/google/gerrit/server/restapi/change/PutMessage.java
index 37318d0..1ed7fd7 100644
--- a/java/com/google/gerrit/server/restapi/change/PutMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/PutMessage.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.CommitMessageInput;
-import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -34,7 +33,6 @@
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.PatchSetInserter;
-import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -72,7 +70,6 @@
   private final PatchSetUtil psUtil;
   private final NotifyResolver notifyResolver;
   private final ProjectCache projectCache;
-  private final DynamicItem<UrlFormatter> urlFormatter;
 
   @Inject
   PutMessage(
@@ -84,8 +81,7 @@
       @GerritPersonIdent PersonIdent gerritIdent,
       PatchSetUtil psUtil,
       NotifyResolver notifyResolver,
-      ProjectCache projectCache,
-      DynamicItem<UrlFormatter> urlFormatter) {
+      ProjectCache projectCache) {
     this.updateFactory = updateFactory;
     this.repositoryManager = repositoryManager;
     this.userProvider = userProvider;
@@ -95,7 +91,6 @@
     this.psUtil = psUtil;
     this.notifyResolver = notifyResolver;
     this.projectCache = projectCache;
-    this.urlFormatter = urlFormatter;
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/change/PutTopic.java b/java/com/google/gerrit/server/restapi/change/PutTopic.java
index 325b80c..3031781 100644
--- a/java/com/google/gerrit/server/restapi/change/PutTopic.java
+++ b/java/com/google/gerrit/server/restapi/change/PutTopic.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.SetTopicOp;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
@@ -60,7 +61,7 @@
       sanitizedInput.topic = sanitizedInput.topic.trim();
     }
 
-    SetTopicOp op = topicOpFactory.create(sanitizedInput);
+    SetTopicOp op = topicOpFactory.create(sanitizedInput.topic);
     try (BatchUpdate u =
         updateFactory.create(req.getChange().getProject(), req.getUser(), TimeUtil.nowTs())) {
       u.addOp(req.getId(), op);
diff --git a/java/com/google/gerrit/server/restapi/change/QueryChanges.java b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
index 3c8157b..cf0d4cf 100644
--- a/java/com/google/gerrit/server/restapi/change/QueryChanges.java
+++ b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
@@ -187,9 +187,7 @@
     int cnt = queries.size();
     List<QueryResult<ChangeData>> results = queryProcessor.query(qb.parse(queries));
     List<List<ChangeInfo>> res =
-        json.create(
-                options, queryProcessor.getAttributesFactory(), queryProcessor.getInfosFactory())
-            .format(results);
+        json.create(options, queryProcessor.getInfosFactory()).format(results);
     for (int n = 0; n < cnt; n++) {
       List<ChangeInfo> info = res.get(n);
       if (results.get(n).more() && !info.isEmpty()) {
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index 75ba4c1..cfdf04d 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -42,6 +42,7 @@
 import com.google.gerrit.server.change.RebaseUtil;
 import com.google.gerrit.server.change.RebaseUtil.Base;
 import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -115,7 +116,7 @@
     try (Repository repo = repoManager.openRepository(change.getProject());
         ObjectInserter oi = repo.newObjectInserter();
         ObjectReader reader = oi.newReader();
-        RevWalk rw = new RevWalk(reader);
+        RevWalk rw = CodeReviewCommit.newRevWalk(reader);
         BatchUpdate bu =
             updateFactory.create(change.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       if (!change.isNew()) {
@@ -124,18 +125,23 @@
         throw new ResourceConflictException(
             "cannot rebase merge commits or commit with no ancestor");
       }
-      // TODO(dborowitz): Why no notification? This seems wrong; dig up blame.
-      bu.setNotify(NotifyResolver.Result.none());
-      bu.setRepository(repo, rw, oi);
-      bu.addOp(
-          change.getId(),
+      RebaseChangeOp rebaseOp =
           rebaseFactory
               .create(rsrc.getNotes(), rsrc.getPatchSet(), findBaseRev(repo, rw, rsrc, input))
               .setForceContentMerge(true)
-              .setFireRevisionCreated(true));
+              .setAllowConflicts(input.allowConflicts)
+              .setFireRevisionCreated(true);
+      // TODO(dborowitz): Why no notification? This seems wrong; dig up blame.
+      bu.setNotify(NotifyResolver.Result.none());
+      bu.setRepository(repo, rw, oi);
+      bu.addOp(change.getId(), rebaseOp);
       bu.execute();
+
+      ChangeInfo changeInfo = json.create(OPTIONS).format(change.getProject(), change.getId());
+      changeInfo.containsGitConflicts =
+          !rebaseOp.getRebasedCommit().getFilesWithGitConflicts().isEmpty() ? true : null;
+      return Response.ok(changeInfo);
     }
-    return Response.ok(json.create(OPTIONS).format(change.getProject(), change.getId()));
   }
 
   private ObjectId findBaseRev(
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/ReviewerRecommender.java b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
index 39df82d..d80ab696 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
@@ -182,7 +182,7 @@
     // Sort results
     Stream<Map.Entry<Account.Id, MutableDouble>> sorted =
         reviewerScores.entrySet().stream()
-            .sorted(Collections.reverseOrder(Map.Entry.comparingByValue()));
+            .sorted(Map.Entry.comparingByValue(Collections.reverseOrder()));
     List<Account.Id> sortedSuggestions = sorted.map(Map.Entry::getKey).collect(toList());
     logger.atFine().log("Sorted suggestions: %s", sortedSuggestions);
     return sortedSuggestions;
@@ -202,7 +202,7 @@
       double baseWeight, String query, List<Account.Id> candidateList)
       throws IOException, ConfigInvalidException {
     int numberOfRelevantChanges = config.getInt("suggest", "relevantChanges", 50);
-    // Get the user's last 25 changes, check reviewers
+    // Get the user's last numberOfRelevantChanges changes, check reviewers
     try {
       List<ChangeData> result =
           queryProvider
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index e77bfe7..790b2db 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -30,8 +30,10 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -66,12 +68,14 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Config;
@@ -373,8 +377,15 @@
 
   public Collection<ChangeData> unmergeableChanges(ChangeSet cs) throws IOException {
     Set<ChangeData> mergeabilityMap = new HashSet<>();
+    Set<ObjectId> outDatedPatchsets = new HashSet<>();
     for (ChangeData change : cs.changes()) {
       mergeabilityMap.add(change);
+      // Add all the patchsets commit ids except the current patchset.
+      outDatedPatchsets.addAll(
+          change.notes().getPatchSets().values().stream()
+              .map(p -> p.commitId())
+              .collect(Collectors.toSet()));
+      outDatedPatchsets.remove(change.currentPatchSet().commitId());
     }
 
     ListMultimap<BranchNameKey, ChangeData> cbb = cs.changesByBranch();
@@ -388,12 +399,17 @@
           allParents.add(parent.getId());
         }
       }
-
       for (ChangeData change : targetBranch) {
+
         RevCommit commit = commits.get(change.getId());
         boolean isMergeCommit = commit.getParentCount() > 1;
         boolean isLastInChain = !allParents.contains(commit.getId());
-
+        if (Arrays.stream(commit.getParents()).anyMatch(c -> outDatedPatchsets.contains(c.getId()))
+            && !isCherryPickSubmit(change)) {
+          // Found a parent that depends on an outdated patchset and the submit strategy is not
+          // cherry-pick.
+          continue;
+        }
         // Recheck mergeability rather than using value stored in the index,
         // which may be stale.
         // TODO(dborowitz): This is ugly; consider providing a way to not read
@@ -419,6 +435,11 @@
     return mergeabilityMap;
   }
 
+  private boolean isCherryPickSubmit(ChangeData changeData) {
+    SubmitTypeRecord submitTypeRecord = changeData.submitTypeRecord();
+    return submitTypeRecord.isOk() && submitTypeRecord.type == SubmitType.CHERRY_PICK;
+  }
+
   private HashMap<Change.Id, RevCommit> findCommits(
       Collection<ChangeData> changes, Project.NameKey project) throws IOException {
     HashMap<Change.Id, RevCommit> commits = new HashMap<>();
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index 780c60a..0a5692e 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -222,7 +222,6 @@
     info.showAssigneeInChangesTable =
         toBoolean(
             config.getBoolean("change", "showAssigneeInChangesTable", false) && hasAssigneeInIndex);
-    info.largeChange = config.getInt("change", "largeChange", 500);
     info.replyTooltip =
         Optional.ofNullable(config.getString("change", null, "replyTooltip"))
                 .orElse("Reply and score")
@@ -305,6 +304,7 @@
     info.editGpgKeys =
         toBoolean(enableSignedPush && config.getBoolean("gerrit", null, "editGpgKeys", true));
     info.primaryWeblinkName = config.getString("gerrit", null, "primaryWeblinkName");
+    info.instanceId = config.getString("gerrit", null, "instanceId");
     return info;
   }
 
diff --git a/java/com/google/gerrit/server/restapi/group/CreateGroup.java b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
index 74ca721..613c805 100644
--- a/java/com/google/gerrit/server/restapi/group/CreateGroup.java
+++ b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
@@ -54,6 +54,7 @@
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.server.validators.GroupCreationValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
@@ -66,6 +67,7 @@
 import java.util.List;
 import java.util.Locale;
 import java.util.Optional;
+import java.util.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -75,7 +77,7 @@
 public class CreateGroup
     implements RestCollectionCreateView<TopLevelResource, GroupResource, GroupInput> {
   private final Provider<IdentifiedUser> self;
-  private final PersonIdent serverIdent;
+  private final TimeZone serverTimeZone;
   private final Provider<GroupsUpdate> groupsUpdateProvider;
   private final GroupCache groupCache;
   private final GroupResolver groups;
@@ -89,7 +91,7 @@
   @Inject
   CreateGroup(
       Provider<IdentifiedUser> self,
-      @GerritPersonIdent PersonIdent serverIdent,
+      @GerritPersonIdent Provider<PersonIdent> serverIdent,
       @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider,
       GroupCache groupCache,
       GroupResolver groups,
@@ -100,7 +102,7 @@
       @GerritServerConfig Config cfg,
       Sequences sequences) {
     this.self = self;
-    this.serverIdent = serverIdent;
+    this.serverTimeZone = serverIdent.get().getTimeZone();
     this.groupsUpdateProvider = groupsUpdateProvider;
     this.groupCache = groupCache;
     this.groups = groups;
@@ -210,7 +212,7 @@
             createGroupArgs.uuid,
             GroupUuid.make(
                 createGroupArgs.getGroupName(),
-                self.get().newCommitterIdent(serverIdent.getWhen(), serverIdent.getTimeZone())));
+                self.get().newCommitterIdent(TimeUtil.nowTs(), serverTimeZone)));
     InternalGroupCreation groupCreation =
         InternalGroupCreation.builder()
             .setGroupUUID(uuid)
diff --git a/java/com/google/gerrit/server/restapi/project/CheckAccess.java b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
index 037a953..37616cd 100644
--- a/java/com/google/gerrit/server/restapi/project/CheckAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2017 The Android Open Source Project
+// Copyright (C) 2018 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -17,6 +17,7 @@
 import static com.google.gerrit.entities.RefNames.REFS_HEADS;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.extensions.api.config.AccessCheckInfo;
@@ -25,9 +26,10 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.permissions.DefaultPermissionMappings;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -36,15 +38,14 @@
 import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
-import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Optional;
 import javax.servlet.http.HttpServletResponse;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Repository;
+import org.kohsuke.args4j.Option;
 
-@Singleton
-public class CheckAccess implements RestModifyView<ProjectResource, AccessCheckInput> {
+public class CheckAccess implements RestReadView<ProjectResource> {
   private final AccountResolver accountResolver;
   private final PermissionBackend permissionBackend;
   private final GitRepositoryManager gitRepositoryManager;
@@ -59,7 +60,15 @@
     this.gitRepositoryManager = gitRepositoryManager;
   }
 
-  @Override
+  @Option(name = "--ref", usage = "ref name to check permission for")
+  String refName;
+
+  @Option(name = "--account", usage = "account to check acccess for")
+  String account;
+
+  @Option(name = "--perm", usage = "permission to check; default: read of any ref.")
+  String permission;
+
   public Response<AccessCheckInfo> apply(ProjectResource rsrc, AccessCheckInput input)
       throws PermissionBackendException, RestApiException, IOException, ConfigInvalidException {
     permissionBackend.user(rsrc.getUser()).check(GlobalPermission.VIEW_ACCESS);
@@ -73,60 +82,90 @@
       throw new BadRequestException("input requires 'account'");
     }
 
-    Account.Id match = accountResolver.resolve(input.account).asUnique().account().id();
+    try (TraceContext traceContext = TraceContext.open()) {
+      traceContext.enableAclLogging();
 
-    AccessCheckInfo info = new AccessCheckInfo();
-    try {
-      permissionBackend
-          .absentUser(match)
-          .project(rsrc.getNameKey())
-          .check(ProjectPermission.ACCESS);
-    } catch (AuthException e) {
-      info.message = String.format("user %s cannot see project %s", match, rsrc.getName());
-      info.status = HttpServletResponse.SC_FORBIDDEN;
-      return Response.ok(info);
-    }
+      Account.Id match = accountResolver.resolve(input.account).asUnique().account().id();
 
-    RefPermission refPerm;
-    if (!Strings.isNullOrEmpty(input.permission)) {
-      if (Strings.isNullOrEmpty(input.ref)) {
-        throw new BadRequestException("must set 'ref' when specifying 'permission'");
-      }
-      Optional<RefPermission> rp = DefaultPermissionMappings.refPermission(input.permission);
-      if (!rp.isPresent()) {
-        throw new BadRequestException(
-            String.format("'%s' is not recognized as ref permission", input.permission));
-      }
-
-      refPerm = rp.get();
-    } else {
-      refPerm = RefPermission.READ;
-    }
-
-    if (!Strings.isNullOrEmpty(input.ref)) {
       try {
         permissionBackend
             .absentUser(match)
-            .ref(BranchNameKey.create(rsrc.getNameKey(), input.ref))
-            .check(refPerm);
+            .project(rsrc.getNameKey())
+            .check(ProjectPermission.ACCESS);
       } catch (AuthException e) {
-        info.status = HttpServletResponse.SC_FORBIDDEN;
-        info.message =
-            String.format(
-                "user %s lacks permission %s for %s in project %s",
-                match, input.permission, input.ref, rsrc.getName());
-        return Response.ok(info);
+        return Response.ok(
+            createInfo(
+                traceContext,
+                HttpServletResponse.SC_FORBIDDEN,
+                String.format("user %s cannot see project %s", match, rsrc.getName())));
       }
-    } else {
-      // We say access is okay if there are no refs, but this warrants a warning,
-      // as access denied looks the same as no branches to the user.
-      try (Repository repo = gitRepositoryManager.openRepository(rsrc.getNameKey())) {
-        if (repo.getRefDatabase().getRefsByPrefix(REFS_HEADS).isEmpty()) {
-          info.message = "access is OK, but repository has no branches under refs/heads/";
+
+      RefPermission refPerm;
+      if (!Strings.isNullOrEmpty(input.permission)) {
+        if (Strings.isNullOrEmpty(input.ref)) {
+          throw new BadRequestException("must set 'ref' when specifying 'permission'");
+        }
+        Optional<RefPermission> rp = DefaultPermissionMappings.refPermission(input.permission);
+        if (!rp.isPresent()) {
+          throw new BadRequestException(
+              String.format("'%s' is not recognized as ref permission", input.permission));
+        }
+
+        refPerm = rp.get();
+      } else {
+        refPerm = RefPermission.READ;
+      }
+
+      String message = null;
+      if (!Strings.isNullOrEmpty(input.ref)) {
+        try {
+          permissionBackend
+              .absentUser(match)
+              .ref(BranchNameKey.create(rsrc.getNameKey(), input.ref))
+              .check(refPerm);
+        } catch (AuthException e) {
+          return Response.ok(
+              createInfo(
+                  traceContext,
+                  HttpServletResponse.SC_FORBIDDEN,
+                  String.format(
+                      "user %s lacks permission %s for %s in project %s",
+                      match, input.permission, input.ref, rsrc.getName())));
+        }
+      } else {
+        // We say access is okay if there are no refs, but this warrants a warning,
+        // as access denied looks the same as no branches to the user.
+        try (Repository repo = gitRepositoryManager.openRepository(rsrc.getNameKey())) {
+          if (repo.getRefDatabase().getRefsByPrefix(REFS_HEADS).isEmpty()) {
+            message = "access is OK, but repository has no branches under refs/heads/";
+          }
         }
       }
+      return Response.ok(createInfo(traceContext, HttpServletResponse.SC_OK, message));
     }
-    info.status = HttpServletResponse.SC_OK;
-    return Response.ok(info);
+  }
+
+  private AccessCheckInfo createInfo(TraceContext traceContext, int statusCode, String message) {
+    AccessCheckInfo info = new AccessCheckInfo();
+    info.status = statusCode;
+    info.message = message;
+    info.debugLogs = traceContext.getAclLogRecords();
+    if (info.debugLogs.isEmpty()) {
+      info.debugLogs =
+          ImmutableList.of("Found no rules that apply, so defaulting to no permission");
+    }
+    return info;
+  }
+
+  @Override
+  public Response<AccessCheckInfo> apply(ProjectResource rsrc)
+      throws PermissionBackendException, RestApiException, IOException, ConfigInvalidException {
+
+    AccessCheckInput input = new AccessCheckInput();
+    input.ref = refName;
+    input.account = account;
+    input.permission = permission;
+
+    return apply(rsrc, input);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/CheckAccessReadView.java b/java/com/google/gerrit/server/restapi/project/CheckAccessReadView.java
deleted file mode 100644
index 6aaa678..0000000
--- a/java/com/google/gerrit/server/restapi/project/CheckAccessReadView.java
+++ /dev/null
@@ -1,62 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.restapi.project;
-
-import com.google.gerrit.extensions.api.config.AccessCheckInfo;
-import com.google.gerrit.extensions.api.config.AccessCheckInput;
-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.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ProjectResource;
-import com.google.inject.Inject;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.kohsuke.args4j.Option;
-
-public class CheckAccessReadView implements RestReadView<ProjectResource> {
-  String refName;
-  String account;
-  String permission;
-
-  @Inject CheckAccess checkAccess;
-
-  @Option(name = "--ref", usage = "ref name to check permission for")
-  void addOption(String refName) {
-    this.refName = refName;
-  }
-
-  @Option(name = "--account", usage = "account to check acccess for")
-  void setAccount(String account) {
-    this.account = account;
-  }
-
-  @Option(name = "--perm", usage = "permission to check; default: read of any ref.")
-  void setPermission(String perm) {
-    this.permission = perm;
-  }
-
-  @Override
-  public Response<AccessCheckInfo> apply(ProjectResource rsrc)
-      throws PermissionBackendException, RestApiException, IOException, ConfigInvalidException {
-
-    AccessCheckInput input = new AccessCheckInput();
-    input.ref = refName;
-    input.account = account;
-    input.permission = permission;
-
-    return checkAccess.apply(rsrc, input);
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/project/CheckMergeability.java b/java/com/google/gerrit/server/restapi/project/CheckMergeability.java
index da6ff14..4730318 100644
--- a/java/com/google/gerrit/server/restapi/project/CheckMergeability.java
+++ b/java/com/google/gerrit/server/restapi/project/CheckMergeability.java
@@ -101,10 +101,20 @@
       }
 
       RevCommit targetCommit = rw.parseCommit(destRef.getObjectId());
-      RevCommit sourceCommit = MergeUtil.resolveCommit(git, rw, source);
 
-      if (!commits.canRead(resource.getProjectState(), git, sourceCommit)) {
-        throw new BadRequestException("do not have read permission for: " + source);
+      RevCommit sourceCommit = null;
+      try {
+        sourceCommit = MergeUtil.resolveCommit(git, rw, source);
+        if (!commits.canRead(resource.getProjectState(), git, sourceCommit)) {
+          throw new BadRequestException("do not have read permission for: " + source);
+        }
+      } catch (BadRequestException e) {
+        // Throw a unified exception for permission denied and unresolvable commits.
+        throw new BadRequestException(
+            "Error resolving: '"
+                + source
+                + "'. Do not have read permission, or failed to resolve to a commit.",
+            e);
       }
 
       if (rw.isMergedInto(sourceCommit, targetCommit)) {
diff --git a/java/com/google/gerrit/server/restapi/project/CommitsCollection.java b/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
index 21d7f0b..033463c 100644
--- a/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -48,10 +49,12 @@
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
+/** The collection of commit IDs (ie. 40 char hex IDs) */
 @Singleton
 public class CommitsCollection implements ChildCollection<ProjectResource, CommitResource> {
   private final DynamicMap<RestView<CommitResource>> views;
@@ -93,10 +96,12 @@
     try (Repository repo = repoManager.openRepository(parent.getNameKey());
         RevWalk rw = new RevWalk(repo)) {
       RevCommit commit = rw.parseCommit(objectId);
-      rw.parseBody(commit);
       if (!canRead(parent.getProjectState(), repo, commit)) {
         throw new ResourceNotFoundException(id);
       }
+      // GetCommit depends on the body of both the commit and parent being parsed, to get the
+      // subject.
+      rw.parseBody(commit);
       for (int i = 0; i < commit.getParentCount(); i++) {
         rw.parseBody(rw.parseCommit(commit.getParent(i)));
       }
@@ -171,9 +176,8 @@
     // If we have already checked change refs using the change index, spare any further checks for
     // changes.
     List<Ref> refs =
-        repo.getRefDatabase().getRefs().stream()
-            .filter(r -> !r.getName().startsWith(RefNames.REFS_CHANGES))
-            .collect(toImmutableList());
+        repo.getRefDatabase()
+            .getRefsByPrefixWithExclusions(RefDatabase.ALL, ImmutableSet.of(RefNames.REFS_CHANGES));
     return reachable.fromRefs(project, repo, commit, refs);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java b/java/com/google/gerrit/server/restapi/project/ConfigInfoCreator.java
similarity index 77%
rename from java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java
rename to java/com/google/gerrit/server/restapi/project/ConfigInfoCreator.java
index 783b39b..904a16f 100644
--- a/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java
+++ b/java/com/google/gerrit/server/restapi/project/ConfigInfoCreator.java
@@ -21,6 +21,10 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
+import com.google.gerrit.extensions.api.projects.ConfigInfo.ConfigParameterInfo;
+import com.google.gerrit.extensions.api.projects.ConfigInfo.InheritedBooleanInfo;
+import com.google.gerrit.extensions.api.projects.ConfigInfo.MaxObjectSizeLimitInfo;
+import com.google.gerrit.extensions.api.projects.ConfigInfo.SubmitTypeInfo;
 import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -42,9 +46,12 @@
 import java.util.Map;
 import java.util.TreeMap;
 
-public class ConfigInfoImpl extends ConfigInfo {
+public class ConfigInfoCreator {
+  /** do not instantiate this class. */
+  private ConfigInfoCreator() {}
+
   @SuppressWarnings("deprecation")
-  public ConfigInfoImpl(
+  public static ConfigInfo constructInfo(
       boolean serverEnableSignedPush,
       ProjectState projectState,
       CurrentUser user,
@@ -53,8 +60,9 @@
       AllProjectsName allProjects,
       UiActions uiActions,
       DynamicMap<RestView<ProjectResource>> views) {
+    ConfigInfo configInfo = new ConfigInfo();
     Project p = projectState.getProject();
-    this.description = Strings.emptyToNull(p.getDescription());
+    configInfo.description = Strings.emptyToNull(p.getDescription());
 
     ProjectState parentState = Iterables.getFirst(projectState.parents(), null);
     for (BooleanProjectConfig cfg : BooleanProjectConfig.values()) {
@@ -63,48 +71,51 @@
       if (parentState != null) {
         info.inheritedValue = parentState.is(cfg);
       }
-      BooleanProjectConfigTransformations.set(cfg, this, info);
+      BooleanProjectConfigTransformations.set(cfg, configInfo, info);
     }
 
     if (!serverEnableSignedPush) {
-      this.enableSignedPush = null;
-      this.requireSignedPush = null;
+      configInfo.enableSignedPush = null;
+      configInfo.requireSignedPush = null;
     }
 
-    this.maxObjectSizeLimit = getMaxObjectSizeLimit(projectState, p);
+    configInfo.maxObjectSizeLimit = getMaxObjectSizeLimit(projectState, p);
 
-    this.defaultSubmitType = new SubmitTypeInfo();
-    this.defaultSubmitType.value = projectState.getSubmitType();
-    this.defaultSubmitType.configuredValue =
+    configInfo.defaultSubmitType = new SubmitTypeInfo();
+    configInfo.defaultSubmitType.value = projectState.getSubmitType();
+    configInfo.defaultSubmitType.configuredValue =
         MoreObjects.firstNonNull(
             projectState.getConfig().getProject().getSubmitType(), Project.DEFAULT_SUBMIT_TYPE);
     ProjectState parent =
         projectState.isAllProjects() ? projectState : projectState.parents().get(0);
-    this.defaultSubmitType.inheritedValue = parent.getSubmitType();
+    configInfo.defaultSubmitType.inheritedValue = parent.getSubmitType();
 
-    this.submitType = this.defaultSubmitType.value;
+    configInfo.submitType = configInfo.defaultSubmitType.value;
 
-    this.state =
+    configInfo.state =
         p.getState() != com.google.gerrit.extensions.client.ProjectState.ACTIVE
             ? p.getState()
             : null;
 
-    this.commentlinks = new LinkedHashMap<>();
+    configInfo.commentlinks = new LinkedHashMap<>();
     for (CommentLinkInfo cl : projectState.getCommentLinks()) {
-      this.commentlinks.put(cl.name, cl);
+      configInfo.commentlinks.put(cl.name, cl);
     }
 
-    pluginConfig = getPluginConfig(projectState, pluginConfigEntries, cfgFactory, allProjects);
+    configInfo.pluginConfig =
+        getPluginConfig(projectState, pluginConfigEntries, cfgFactory, allProjects);
 
-    actions = new TreeMap<>();
+    configInfo.actions = new TreeMap<>();
     for (UiAction.Description d : uiActions.from(views, new ProjectResource(projectState, user))) {
-      actions.put(d.getId(), new ActionInfo(d));
+      configInfo.actions.put(d.getId(), new ActionInfo(d));
     }
 
-    this.extensionPanelNames = projectState.getConfig().getExtensionPanelSections();
+    configInfo.extensionPanelNames = projectState.getConfig().getExtensionPanelSections();
+    return configInfo;
   }
 
-  private MaxObjectSizeLimitInfo getMaxObjectSizeLimit(ProjectState projectState, Project p) {
+  private static MaxObjectSizeLimitInfo getMaxObjectSizeLimit(
+      ProjectState projectState, Project p) {
     MaxObjectSizeLimitInfo info = new MaxObjectSizeLimitInfo();
     EffectiveMaxObjectSizeLimit limit = projectState.getEffectiveMaxObjectSizeLimit();
     long value = limit.value;
@@ -114,7 +125,7 @@
     return info;
   }
 
-  private Map<String, Map<String, ConfigParameterInfo>> getPluginConfig(
+  private static Map<String, Map<String, ConfigParameterInfo>> getPluginConfig(
       ProjectState project,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
@@ -162,7 +173,7 @@
     return !pluginConfig.isEmpty() ? pluginConfig : null;
   }
 
-  private String getInheritedValue(
+  private static String getInheritedValue(
       ProjectState project, PluginConfigFactory cfgFactory, Extension<ProjectConfigEntry> e) {
     ProjectConfigEntry configEntry = e.getProvider().get();
     ProjectState parent = Iterables.getFirst(project.parents(), null);
diff --git a/java/com/google/gerrit/server/restapi/project/CreateBranch.java b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
index b901057..2fd2d65 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateBranch.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
@@ -109,6 +109,12 @@
               + MagicBranch.getMagicRefNamePrefix(ref)
               + "\"");
     }
+    if (!isBranchAllowed(ref)) {
+      throw new BadRequestException(
+          "Cannot create a branch with name \""
+              + ref
+              + "\". Not allowed to create branches under Gerrit internal or tags refs.");
+    }
 
     BranchNameKey name = BranchNameKey.create(rsrc.getNameKey(), ref);
     try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
@@ -187,4 +193,9 @@
       throw new BadRequestException("invalid revision \"" + input.revision + "\"", e);
     }
   }
+
+  /** Branches cannot be created under any Gerrit internal or tags refs. */
+  private boolean isBranchAllowed(String branch) {
+    return !RefNames.isGerritRef(branch) && !branch.startsWith(RefNames.REFS_TAGS);
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/CreateChange.java b/java/com/google/gerrit/server/restapi/project/CreateChange.java
index dbcd8c9..59efd06 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateChange.java
@@ -59,8 +59,8 @@
       throw new AuthException("Authentication required");
     }
 
-    if (!Strings.isNullOrEmpty(input.project)) {
-      throw new BadRequestException("may not specify project");
+    if (!Strings.isNullOrEmpty(input.project) && !rsrc.getName().equals(input.project)) {
+      throw new BadRequestException("project must match URL");
     }
 
     input.project = rsrc.getName();
diff --git a/java/com/google/gerrit/server/restapi/project/CreateDashboard.java b/java/com/google/gerrit/server/restapi/project/CreateDashboard.java
index e9a0d7f..34d6696 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateDashboard.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateDashboard.java
@@ -26,11 +26,9 @@
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.Singleton;
 import java.io.IOException;
 import org.kohsuke.args4j.Option;
 
-@Singleton
 public class CreateDashboard
     implements RestCollectionCreateView<ProjectResource, DashboardResource, SetDashboardInput> {
   private final Provider<SetDefaultDashboard> setDefault;
diff --git a/java/com/google/gerrit/server/restapi/project/CreateLabel.java b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
index 3e1ef49..2ae1b05 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
@@ -174,6 +174,11 @@
       labelType.setCopyMaxScore(input.copyMaxScore);
     }
 
+    if (input.copyAllScoresIfListOfFilesDidNotChange != null) {
+      labelType.setCopyAllScoresIfListOfFilesDidNotChange(
+          input.copyAllScoresIfListOfFilesDidNotChange);
+    }
+
     if (input.copyAllScoresIfNoChange != null) {
       labelType.setCopyAllScoresIfNoChange(input.copyAllScoresIfNoChange);
     }
diff --git a/java/com/google/gerrit/server/restapi/project/CreateProject.java b/java/com/google/gerrit/server/restapi/project/CreateProject.java
index a5a0034..faab241 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateProject.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateProject.java
@@ -208,4 +208,18 @@
     }
     return normalizedBranches;
   }
+
+  static class ValidBranchListener implements ProjectCreationValidationListener {
+    @Override
+    public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+      for (String branch : args.branch) {
+        if (RefNames.isGerritRef(branch)) {
+          throw new ValidationException(
+              String.format(
+                  "Cannot create a project with branch %s. Branches in the Gerrit internal refs namespace are not allowed",
+                  branch));
+        }
+      }
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/CreateTag.java b/java/com/google/gerrit/server/restapi/project/CreateTag.java
index 5cfb118..b552ff5 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateTag.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateTag.java
@@ -102,6 +102,8 @@
     try (Repository repo = repoManager.openRepository(resource.getNameKey())) {
       ObjectId revid = RefUtil.parseBaseRevision(repo, resource.getNameKey(), input.revision);
       RevWalk rw = RefUtil.verifyConnected(repo, revid);
+      // Reachability through tags does not influence a commit's visibility, so no need to check for
+      // visibility.
       RevObject object = rw.parseAny(revid);
       rw.reset();
       boolean isAnnotated = Strings.emptyToNull(input.message) != null;
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteRef.java b/java/com/google/gerrit/server/restapi/project/DeleteRef.java
index 2395bdd..4e13ba9 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteRef.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteRef.java
@@ -160,7 +160,7 @@
    *
    * @param projectState the {@code ProjectState} of the project whose refs are to be deleted.
    * @param refsToDelete the refs to be deleted.
-   * @param prefix the prefix of the refs.
+   * @param prefix the prefix to add to abbreviated refs, eg. "refs/heads/".
    * @throws IOException
    * @throws ResourceConflictException
    * @throws PermissionBackendException
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteTag.java b/java/com/google/gerrit/server/restapi/project/DeleteTag.java
index 545b752..8d0a3d3 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteTag.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteTag.java
@@ -14,10 +14,8 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import static com.google.gerrit.entities.RefNames.isConfigRef;
-
+import com.google.common.base.Preconditions;
 import com.google.gerrit.extensions.common.Input;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -27,6 +25,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import org.eclipse.jgit.lib.Constants;
 
 @Singleton
 public class DeleteTag implements RestModifyView<TagResource, Input> {
@@ -43,10 +42,7 @@
       throws RestApiException, IOException, PermissionBackendException {
     String tag = RefUtil.normalizeTagRef(resource.getTagInfo().ref);
 
-    if (isConfigRef(tag)) {
-      // Never allow to delete the meta config branch.
-      throw new MethodNotAllowedException("not allowed to delete " + tag);
-    }
+    Preconditions.checkState(tag.startsWith(Constants.R_TAGS));
 
     deleteRef.deleteSingleRef(resource.getProjectState(), tag);
     return Response.none();
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteTags.java b/java/com/google/gerrit/server/restapi/project/DeleteTags.java
index 6e8ec37..7ac3aff 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteTags.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteTags.java
@@ -43,6 +43,10 @@
     if (input == null || input.tags == null || input.tags.isEmpty()) {
       throw new BadRequestException("tags must be specified");
     }
+
+    // If input.tags = ["refs/heads/bla"], this will actually delete the 'ref/heads/bla' branch,
+    // rather than refs/tags/refs/heads/bla.
+    // Since this is checked against DELETE permissions for refs/heads/bla, we'll let it go through.
     deleteRef.deleteMultipleRefs(
         project.getProjectState(), ImmutableSet.copyOf(input.tags), R_TAGS);
     return Response.none();
diff --git a/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java b/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
index 0d5ab88..7bee2f2 100644
--- a/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.gerrit.entities.Patch;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.ChildCollection;
@@ -27,7 +26,6 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.change.FileInfoJson;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.patch.PatchListKey;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.project.CommitResource;
 import com.google.gerrit.server.project.FileResource;
@@ -39,6 +37,10 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.kohsuke.args4j.Option;
 
+/**
+ * like {@link FilesCollection}, but for commits that are specified as hex ID, rather than branch
+ * names.
+ */
 @Singleton
 public class FilesInCommitCollection implements ChildCollection<CommitResource, FileResource> {
   private final DynamicMap<RestView<FileResource>> views;
@@ -85,21 +87,18 @@
       this.fileInfoJson = fileInfoJson;
     }
 
+    public ListFiles setParent(int parentNum) {
+      this.parentNum = parentNum;
+      return this;
+    }
+
     @Override
     public Response<Map<String, FileInfo>> apply(CommitResource resource)
         throws ResourceConflictException, PatchListNotAvailableException {
       RevCommit commit = resource.getCommit();
-      PatchListKey key;
-
-      if (parentNum > 0) {
-        key =
-            PatchListKey.againstParentNum(
-                parentNum, commit, DiffPreferencesInfo.Whitespace.IGNORE_NONE);
-      } else {
-        key = PatchListKey.againstCommit(null, commit, DiffPreferencesInfo.Whitespace.IGNORE_NONE);
-      }
-
-      return Response.ok(fileInfoJson.toFileInfoMap(resource.getProjectState().getNameKey(), key));
+      return Response.ok(
+          fileInfoJson.getFileInfoMap(
+              resource.getProjectState().getNameKey(), commit, parentNum - 1));
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/GetConfig.java b/java/com/google/gerrit/server/restapi/project/GetConfig.java
index ad66587..8ffd5ec 100644
--- a/java/com/google/gerrit/server/restapi/project/GetConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/GetConfig.java
@@ -67,7 +67,7 @@
             .project(resource.getNameKey())
             .test(ProjectPermission.READ_CONFIG);
     return Response.ok(
-        new ConfigInfoImpl(
+        ConfigInfoCreator.constructInfo(
             serverEnableSignedPush,
             resource.getProjectState(),
             resource.getUser(),
diff --git a/java/com/google/gerrit/server/restapi/project/ListBranches.java b/java/com/google/gerrit/server/restapi/project/ListBranches.java
index fecdc8e..2c26933 100644
--- a/java/com/google/gerrit/server/restapi/project/ListBranches.java
+++ b/java/com/google/gerrit/server/restapi/project/ListBranches.java
@@ -143,7 +143,11 @@
   BranchInfo toBranchInfo(BranchResource rsrc)
       throws IOException, ResourceNotFoundException, PermissionBackendException {
     try (Repository db = repoManager.openRepository(rsrc.getNameKey())) {
-      Ref r = db.exactRef(rsrc.getRef());
+      String refName = rsrc.getRef();
+      if (RefNames.isRefsUsersSelf(refName, rsrc.getProjectState().isAllUsers())) {
+        refName = RefNames.refsUsers(rsrc.getUser().getAccountId());
+      }
+      Ref r = db.exactRef(refName);
       if (r == null) {
         throw new ResourceNotFoundException();
       }
diff --git a/java/com/google/gerrit/server/restapi/project/ListChildProjects.java b/java/com/google/gerrit/server/restapi/project/ListChildProjects.java
index 0bd053e..fd18c8d 100644
--- a/java/com/google/gerrit/server/restapi/project/ListChildProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/ListChildProjects.java
@@ -23,9 +23,7 @@
 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.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.ChildProjects;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
@@ -41,16 +39,11 @@
   @Option(name = "--limit", usage = "maximum number of parents projects to list")
   private int limit;
 
-  private final PermissionBackend permissionBackend;
   private final ChildProjects childProjects;
   private final Provider<QueryProjects> queryProvider;
 
   @Inject
-  ListChildProjects(
-      PermissionBackend permissionBackend,
-      ChildProjects childProjects,
-      Provider<QueryProjects> queryProvider) {
-    this.permissionBackend = permissionBackend;
+  ListChildProjects(ChildProjects childProjects, Provider<QueryProjects> queryProvider) {
     this.childProjects = childProjects;
     this.queryProvider = queryProvider;
   }
@@ -83,10 +76,7 @@
   }
 
   private List<ProjectInfo> directChildProjects(Project.NameKey parent) throws RestApiException {
-    PermissionBackend.WithUser currentUser = permissionBackend.currentUser();
     return queryProvider.get().withQuery("parent:" + parent.get()).withLimit(limit).apply().stream()
-        .filter(
-            p -> currentUser.project(Project.nameKey(p.name)).testOrFalse(ProjectPermission.ACCESS))
         .collect(toList());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/ListProjects.java b/java/com/google/gerrit/server/restapi/project/ListProjects.java
index 5418876..c4ae33a 100644
--- a/java/com/google/gerrit/server/restapi/project/ListProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/ListProjects.java
@@ -90,7 +90,11 @@
 import org.eclipse.jgit.lib.Repository;
 import org.kohsuke.args4j.Option;
 
-/** List projects visible to the calling user. */
+/**
+ * List projects visible to the calling user.
+ *
+ * <p>Implement {@code GET /projects/}, without a {@code query=} parameter.
+ */
 public class ListProjects implements RestReadView<TopLevelResource> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -486,7 +490,7 @@
                 continue;
               }
 
-              List<Ref> refs = retieveBranchRefs(e);
+              List<Ref> refs = retrieveBranchRefs(e);
               if (!hasValidRef(refs)) {
                 continue;
               }
@@ -574,7 +578,7 @@
     }
   }
 
-  private List<Ref> retieveBranchRefs(ProjectState e) throws PermissionBackendException {
+  private List<Ref> retrieveBranchRefs(ProjectState e) throws PermissionBackendException {
     boolean canReadAllRefs = e.statePermitsRead();
     if (canReadAllRefs) {
       try {
diff --git a/java/com/google/gerrit/server/restapi/project/Module.java b/java/com/google/gerrit/server/restapi/project/Module.java
index ee3914d..9217077 100644
--- a/java/com/google/gerrit/server/restapi/project/Module.java
+++ b/java/com/google/gerrit/server/restapi/project/Module.java
@@ -29,8 +29,10 @@
 import com.google.gerrit.server.config.GerritConfigListener;
 import com.google.gerrit.server.project.RefValidationHelper;
 import com.google.gerrit.server.restapi.change.CherryPickCommit;
+import com.google.gerrit.server.validators.ProjectCreationValidationListener;
 
 public class Module extends RestApiModule {
+
   @Override
   protected void configure() {
     bind(ProjectsCollection.class);
@@ -46,6 +48,8 @@
     DynamicMap.mapOf(binder(), LABEL_KIND);
 
     DynamicSet.bind(binder(), GerritConfigListener.class).to(SetParent.class);
+    DynamicSet.bind(binder(), ProjectCreationValidationListener.class)
+        .to(CreateProject.ValidBranchListener.class);
 
     create(PROJECT_KIND).to(CreateProject.class);
     put(PROJECT_KIND).to(PutProject.class);
@@ -57,7 +61,7 @@
     get(PROJECT_KIND, "access").to(GetAccess.class);
     post(PROJECT_KIND, "access").to(SetAccess.class);
     put(PROJECT_KIND, "access:review").to(CreateAccessChange.class);
-    get(PROJECT_KIND, "check.access").to(CheckAccessReadView.class);
+    get(PROJECT_KIND, "check.access").to(CheckAccess.class);
 
     post(PROJECT_KIND, "check").to(Check.class);
 
@@ -79,10 +83,7 @@
 
     put(PROJECT_KIND, "ban").to(BanCommit.class);
 
-    get(PROJECT_KIND, "statistics.git").to(GetStatistics.class);
-    post(PROJECT_KIND, "gc").to(GarbageCollect.class);
     post(PROJECT_KIND, "index").to(Index.class);
-    post(PROJECT_KIND, "index.changes").to(IndexChanges.class);
 
     child(PROJECT_KIND, "branches").to(BranchesCollection.class);
     create(BRANCH_KIND).to(CreateBranch.class);
@@ -121,4 +122,14 @@
 
     factory(ProjectNode.Factory.class);
   }
+
+  /** Separately bind batch functionality. */
+  public static class BatchModule extends RestApiModule {
+    @Override
+    protected void configure() {
+      get(PROJECT_KIND, "statistics.git").to(GetStatistics.class);
+      post(PROJECT_KIND, "gc").to(GarbageCollect.class);
+      post(PROJECT_KIND, "index.changes").to(IndexChanges.class);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java b/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
index f92624f..efc739c 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
@@ -41,11 +41,9 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Optional;
 
-@Singleton
 public class ProjectsCollection
     implements RestCollection<TopLevelResource, ProjectResource>, NeedsParams {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfig.java b/java/com/google/gerrit/server/restapi/project/PutConfig.java
index 55ea312..afa08cd 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfig.java
@@ -176,7 +176,7 @@
       }
 
       ProjectState state = projectStateFactory.create(projectConfigFactory.read(md).getCacheable());
-      return new ConfigInfoImpl(
+      return ConfigInfoCreator.constructInfo(
           serverEnableSignedPush,
           state,
           user.get(),
diff --git a/java/com/google/gerrit/server/restapi/project/QueryProjects.java b/java/com/google/gerrit/server/restapi/project/QueryProjects.java
index e4f7df5..a9d818d 100644
--- a/java/com/google/gerrit/server/restapi/project/QueryProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/QueryProjects.java
@@ -36,6 +36,7 @@
 import java.util.List;
 import org.kohsuke.args4j.Option;
 
+/** Implements the {@code GET /projects/?query=QUERY} endpoint. */
 public class QueryProjects implements RestReadView<TopLevelResource> {
   private final ProjectIndexCollection indexes;
   private final ProjectQueryBuilder queryBuilder;
diff --git a/java/com/google/gerrit/server/restapi/project/SetLabel.java b/java/com/google/gerrit/server/restapi/project/SetLabel.java
index ffc591b..b1bcb15 100644
--- a/java/com/google/gerrit/server/restapi/project/SetLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/SetLabel.java
@@ -189,6 +189,12 @@
       dirty = true;
     }
 
+    if (input.copyAllScoresIfListOfFilesDidNotChange != null) {
+      labelTypeBuilder.setCopyAllScoresIfListOfFilesDidNotChange(
+          input.copyAllScoresIfListOfFilesDidNotChange);
+      dirty = true;
+    }
+
     if (input.copyAllScoresIfNoChange != null) {
       labelTypeBuilder.setCopyAllScoresIfNoChange(input.copyAllScoresIfNoChange);
       dirty = true;
diff --git a/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
index 6faaec5..9907b1c 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -176,14 +176,16 @@
           grant(config, refsFor, Permission.ADD_PATCH_SET, registered);
         });
 
+    config.upsertAccessSection(
+        "refs/meta/version",
+        version -> {
+          grant(config, version, Permission.READ, anonymous);
+        });
+
     grant(config, heads, codeReviewLabel, -1, 1, registered);
     grant(config, heads, Permission.FORGE_AUTHOR, registered);
-
-    config.upsertAccessSection(
-        "refs/*",
-        all -> {
-          grant(config, all, Permission.REVERT, registered);
-        });
+    grant(config, heads, Permission.READ, anonymous);
+    grant(config, heads, Permission.REVERT, registered);
 
     config.upsertAccessSection(
         "refs/for/" + AccessSection.ALL,
@@ -213,7 +215,7 @@
     config.upsertAccessSection(
         AccessSection.ALL,
         all -> {
-          grant(config, all, Permission.READ, adminsGroup, anonymous);
+          grant(config, all, Permission.READ, adminsGroup);
         });
 
     config.upsertAccessSection(
diff --git a/java/com/google/gerrit/server/schema/AllUsersCreator.java b/java/com/google/gerrit/server/schema/AllUsersCreator.java
index 90973fb..3588860 100644
--- a/java/com/google/gerrit/server/schema/AllUsersCreator.java
+++ b/java/com/google/gerrit/server/schema/AllUsersCreator.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.common.Version;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
@@ -83,7 +84,8 @@
   @UsedAt(UsedAt.Project.GOOGLE)
   public AllUsersCreator setCodeReviewLabel(LabelType labelType) {
     checkArgument(
-        labelType.getName().equals("Code-Review"), "label should have 'Code-Review' as its name");
+        labelType.getName().equals(LabelId.CODE_REVIEW),
+        "label should have 'Code-Review' as its name");
     this.codeReviewLabel = labelType;
     return this;
   }
diff --git a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
index afa9d1a..de9374e 100644
--- a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
+++ b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.GroupUuid;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
@@ -91,7 +92,7 @@
   @Override
   public void create() throws IOException, ConfigInvalidException {
     GroupReference admins = createGroupReference("Administrators");
-    GroupReference serviceUsers = createGroupReference("Service Users");
+    GroupReference serviceUsers = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
 
     AllProjectsInput allProjectsInput =
         AllProjectsInput.builder()
diff --git a/java/com/google/gerrit/server/schema/Schema_184.java b/java/com/google/gerrit/server/schema/Schema_184.java
index d0ca3d0..c14ae8a 100644
--- a/java/com/google/gerrit/server/schema/Schema_184.java
+++ b/java/com/google/gerrit/server/schema/Schema_184.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.git.RefUpdateUtil;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
@@ -44,7 +45,7 @@
   @Override
   public void upgrade(Arguments args, UpdateUI ui) throws Exception {
     try (Repository allUsersRepo = args.repoManager.openRepository(args.allUsers)) {
-      AccountGroup.NameKey newName = AccountGroup.nameKey("Service Users");
+      AccountGroup.NameKey newName = AccountGroup.nameKey(ServiceUserClassifier.SERVICE_USERS);
       Optional<GroupReference> nonInteractiveUsers =
           GroupNameNotes.loadAllGroups(allUsersRepo).stream()
               .filter(g -> g.getName().equals("Non-Interactive Users"))
diff --git a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
index 5485192..39e3a59 100644
--- a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
+++ b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
@@ -54,14 +54,14 @@
       ImmutableList.of(
           "[access \"refs/*\"]",
           "  read = group Administrators",
-          "  read = group Anonymous Users",
-          "  revert = group Registered Users",
           "[access \"refs/for/*\"]",
           "  addPatchSet = group Registered Users",
           "[access \"refs/for/refs/*\"]",
           "  push = group Registered Users",
           "  pushMerge = group Registered Users",
           "[access \"refs/heads/*\"]",
+          "  read = group Anonymous Users",
+          "  revert = group Registered Users",
           "  create = group Administrators",
           "  create = group Project Owners",
           "  editTopicName = +force group Administrators",
@@ -88,6 +88,8 @@
           "  read = group Project Owners",
           "  submit = group Administrators",
           "  submit = group Project Owners",
+          "[access \"refs/meta/version\"]",
+          "  read = group Anonymous Users",
           "[access \"refs/tags/*\"]",
           "  create = group Administrators",
           "  create = group Project Owners",
diff --git a/java/com/google/gerrit/server/submit/EmailMerge.java b/java/com/google/gerrit/server/submit/EmailMerge.java
index 4efa4c8..109c9c3 100644
--- a/java/com/google/gerrit/server/submit/EmailMerge.java
+++ b/java/com/google/gerrit/server/submit/EmailMerge.java
@@ -31,6 +31,7 @@
 import com.google.inject.Inject;
 import com.google.inject.OutOfScopeException;
 import com.google.inject.assistedinject.Assisted;
+import java.util.Optional;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
 
@@ -43,7 +44,8 @@
         Change change,
         Account.Id submitter,
         NotifyResolver.Result notify,
-        RepoView repoView);
+        RepoView repoView,
+        String stickyApprovalDiff);
   }
 
   private final ExecutorService sendEmailsExecutor;
@@ -57,6 +59,7 @@
   private final Account.Id submitter;
   private final NotifyResolver.Result notify;
   private final RepoView repoView;
+  private final String stickyApprovalDiff;
 
   @Inject
   EmailMerge(
@@ -69,7 +72,8 @@
       @Assisted Change change,
       @Assisted @Nullable Account.Id submitter,
       @Assisted NotifyResolver.Result notify,
-      @Assisted RepoView repoView) {
+      @Assisted RepoView repoView,
+      @Assisted String stickyApprovalDiff) {
     this.sendEmailsExecutor = executor;
     this.mergedSenderFactory = mergedSenderFactory;
     this.requestContext = requestContext;
@@ -80,6 +84,7 @@
     this.submitter = submitter;
     this.notify = notify;
     this.repoView = repoView;
+    this.stickyApprovalDiff = stickyApprovalDiff;
   }
 
   void sendAsync() {
@@ -91,7 +96,8 @@
   public void run() {
     RequestContext old = requestContext.setContext(this);
     try {
-      MergedSender emailSender = mergedSenderFactory.create(project, change.getId());
+      MergedSender emailSender =
+          mergedSenderFactory.create(project, change.getId(), Optional.of(stickyApprovalDiff));
       if (submitter != null) {
         emailSender.setFrom(submitter);
       }
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index fdf3664..f486650 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Change.Status;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
@@ -42,6 +43,7 @@
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitTypeRecord;
+import com.google.gerrit.exceptions.InternalServerWithUserMessageException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
@@ -187,7 +189,7 @@
       // date by this point.
       ChangeData cd = requireNonNull(changes.get(id), () -> String.format("ChangeData for %s", id));
       return requireNonNull(
-          cd.getSubmitRecords(submitRuleOptions(allowClosed)),
+          cd.submitRecords(submitRuleOptions(allowClosed)),
           "getSubmitRecord only valid after submit rules are evalutated");
     }
 
@@ -232,7 +234,7 @@
   private final SubmitStrategyFactory submitStrategyFactory;
   private final SubscriptionGraph.Factory subscriptionGraphFactory;
   private final SubmoduleCommits.Factory submoduleCommitsFactory;
-  private final SubmissionListener superprojectUpdateSubmissionListener;
+  private final ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners;
   private final Provider<MergeOpRepoManager> ormProvider;
   private final NotifyResolver notifyResolver;
   private final RetryHelper retryHelper;
@@ -264,7 +266,8 @@
       SubmitStrategyFactory submitStrategyFactory,
       SubmoduleCommits.Factory submoduleCommitsFactory,
       SubscriptionGraph.Factory subscriptionGraphFactory,
-      @SuperprojectUpdateOnSubmission SubmissionListener superprojectUpdateSubmissionListener,
+      @SuperprojectUpdateOnSubmission
+          ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners,
       Provider<MergeOpRepoManager> ormProvider,
       NotifyResolver notifyResolver,
       TopicMetrics topicMetrics,
@@ -279,7 +282,7 @@
     this.submitStrategyFactory = submitStrategyFactory;
     this.submoduleCommitsFactory = submoduleCommitsFactory;
     this.subscriptionGraphFactory = subscriptionGraphFactory;
-    this.superprojectUpdateSubmissionListener = superprojectUpdateSubmissionListener;
+    this.superprojectUpdateSubmissionListeners = superprojectUpdateSubmissionListeners;
     this.ormProvider = ormProvider;
     this.notifyResolver = notifyResolver;
     this.retryHelper = retryHelper;
@@ -491,18 +494,35 @@
         logger.atFine().log("Calculated to merge %s", indexBackedChangeSet);
 
         // Reload ChangeSet so that we don't rely on (potentially) stale index data for merging
-        ChangeSet cs = reloadChanges(indexBackedChangeSet);
+        ChangeSet noteDbChangeSet = reloadChanges(indexBackedChangeSet);
+
+        // At this point, any change that isn't new can be filtered out since they were only here
+        // in the first place due to stale index.
+        List<ChangeData> filteredChanges = new ArrayList<>();
+        for (ChangeData changeData : noteDbChangeSet.changes()) {
+          if (!changeData.change().getStatus().equals(Status.NEW)) {
+            logger.atFine().log(
+                "Change %s has status %s due to stale index, so it is skipped during submit",
+                changeData.getId().toString(), changeData.change().getStatus().name());
+            continue;
+          }
+          filteredChanges.add(changeData);
+        }
+
+        // There are no hidden changes (or else we would have thrown AuthException above).
+        ChangeSet filteredNoteDbChangeSet =
+            new ChangeSet(filteredChanges, /* hiddenChanges= */ ImmutableList.of());
 
         // Count cross-project submissions outside of the retry loop. The chance of a single project
         // failing increases with the number of projects, so the failure count would be inflated if
         // this metric were incremented inside of integrateIntoHistory.
-        int projects = cs.projects().size();
+        int projects = filteredNoteDbChangeSet.projects().size();
         if (projects > 1) {
           topicMetrics.topicSubmissions.increment();
         }
 
         SubmissionExecutor submissionExecutor =
-            new SubmissionExecutor(dryrun, superprojectUpdateSubmissionListener);
+            new SubmissionExecutor(dryrun, superprojectUpdateSubmissionListeners);
         RetryTracker retryTracker = new RetryTracker();
         retryHelper
             .changeUpdate(
@@ -515,22 +535,25 @@
                     this.ts = TimeUtil.nowTs();
                     openRepoManager();
                   }
-                  this.commitStatus = new CommitStatus(cs, isRetry);
+                  this.commitStatus = new CommitStatus(filteredNoteDbChangeSet, isRetry);
                   if (checkSubmitRules) {
                     logger.atFine().log("Checking submit rules and state");
-                    checkSubmitRulesAndState(cs, isRetry);
+                    checkSubmitRulesAndState(filteredNoteDbChangeSet, isRetry);
                   } else {
                     logger.atFine().log("Bypassing submit rules");
-                    bypassSubmitRules(cs, isRetry);
+                    bypassSubmitRules(filteredNoteDbChangeSet, isRetry);
                   }
-                  integrateIntoHistory(cs, submissionExecutor);
+                  integrateIntoHistory(filteredNoteDbChangeSet, submissionExecutor);
                   return null;
                 })
             .listener(retryTracker)
             // Up to the entire submit operation is retried, including possibly many projects.
             // Multiply the timeout by the number of projects we're actually attempting to
-            // submit.
-            .defaultTimeoutMultiplier(cs.projects().size())
+            // submit. Times 2 to retry more persistently, to increase success rate.
+            .defaultTimeoutMultiplier(filteredNoteDbChangeSet.projects().size() * 2)
+            // By default, we only retry lock failures. Here it's better to also retry unexpected
+            // runtime exceptions.
+            .retryOn(t -> t instanceof RuntimeException)
             .call();
         submissionExecutor.afterExecutions(orm);
 
@@ -672,7 +695,7 @@
       if (e.getCause() instanceof IntegrationConflictException) {
         throw (IntegrationConflictException) e.getCause();
       }
-      throw new StorageException(genericMergeError(cs), e);
+      throw new InternalServerWithUserMessageException(genericMergeError(cs), e);
     }
   }
 
diff --git a/java/com/google/gerrit/server/submit/MergeSuperSet.java b/java/com/google/gerrit/server/submit/MergeSuperSet.java
index 93c78a8..67f2907 100644
--- a/java/com/google/gerrit/server/submit/MergeSuperSet.java
+++ b/java/com/google/gerrit/server/submit/MergeSuperSet.java
@@ -18,19 +18,16 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Strings;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.logging.TraceContext;
-import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginContext;
-import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -55,8 +52,6 @@
  * included.
  */
 public class MergeSuperSet {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private final ChangeData.Factory changeDataFactory;
   private final Provider<InternalChangeQuery> queryProvider;
   private final Provider<MergeOpRepoManager> repoManagerProvider;
@@ -64,7 +59,6 @@
   private final PermissionBackend permissionBackend;
   private final Config cfg;
   private final ProjectCache projectCache;
-  private final ChangeNotes.Factory notesFactory;
 
   private MergeOpRepoManager orm;
   private boolean closeOrm;
@@ -77,8 +71,7 @@
       Provider<MergeOpRepoManager> repoManagerProvider,
       DynamicItem<MergeSuperSetComputation> mergeSuperSetComputation,
       PermissionBackend permissionBackend,
-      ProjectCache projectCache,
-      ChangeNotes.Factory notesFactory) {
+      ProjectCache projectCache) {
     this.cfg = cfg;
     this.changeDataFactory = changeDataFactory;
     this.queryProvider = queryProvider;
@@ -86,7 +79,6 @@
     this.mergeSuperSetComputation = mergeSuperSetComputation;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
-    this.notesFactory = notesFactory;
   }
 
   public static boolean wholeTopicEnabled(Config config) {
@@ -212,24 +204,8 @@
     if (!projectCache.get(cd.project()).map(ProjectState::statePermitsRead).orElse(false)) {
       return false;
     }
-
-    ChangeNotes notes;
     try {
-      notes = cd.notes();
-    } catch (NoSuchChangeException e) {
-      // The change was found in the index but is missing in NoteDb.
-      // This can happen in systems with multiple primary nodes when the replication of the index
-      // documents is faster than the replication of the Git data.
-      // Instead of failing, create the change notes from the index data so that the read permission
-      // check can be performed successfully.
-      logger.atWarning().log(
-          "Got change %d of project %s from index, but couldn't find it in NoteDb",
-          cd.getId().get(), cd.project().get());
-      notes = notesFactory.createFromIndexedChange(cd.change());
-    }
-
-    try {
-      permissionBackend.user(user).change(notes).check(ChangePermission.READ);
+      permissionBackend.user(user).change(cd).check(ChangePermission.READ);
       return true;
     } catch (AuthException e) {
       return false;
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategy.java b/java/com/google/gerrit/server/submit/SubmitStrategy.java
index 21ff2fc..530c53f 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategy.java
@@ -45,6 +45,7 @@
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.validators.OnSubmitValidators;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.patch.SubmitWithStickyApprovalDiff;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectState;
@@ -119,6 +120,7 @@
     final Provider<InternalChangeQuery> queryProvider;
     final ProjectConfig.Factory projectConfigFactory;
     final SetPrivateOp.Factory setPrivateOpFactory;
+    final SubmitWithStickyApprovalDiff submitWithStickyApprovalDiff;
 
     final BranchNameKey destBranch;
     final CodeReviewRevWalk rw;
@@ -159,6 +161,7 @@
         Provider<InternalChangeQuery> queryProvider,
         ProjectConfig.Factory projectConfigFactory,
         SetPrivateOp.Factory setPrivateOpFactory,
+        SubmitWithStickyApprovalDiff submitWithStickyApprovalDiff,
         @Assisted BranchNameKey destBranch,
         @Assisted CommitStatus commitStatus,
         @Assisted CodeReviewRevWalk rw,
@@ -188,6 +191,7 @@
       this.tagCache = tagCache;
       this.queryProvider = queryProvider;
       this.setPrivateOpFactory = setPrivateOpFactory;
+      this.submitWithStickyApprovalDiff = submitWithStickyApprovalDiff;
 
       this.serverIdent = serverIdent;
       this.destBranch = destBranch;
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index 3b77dd9..69207ac 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
@@ -41,6 +42,8 @@
 import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -73,6 +76,7 @@
   private Change updatedChange;
   private CodeReviewCommit alreadyMergedCommit;
   private boolean changeAlreadyMerged;
+  private String stickyApprovalDiff;
 
   protected SubmitStrategyOp(SubmitStrategy.Arguments args, CodeReviewCommit toMerge) {
     this.args = args;
@@ -391,7 +395,9 @@
     }
   }
 
-  private ChangeMessage message(ChangeContext ctx, CodeReviewCommit commit, CommitMergeStatus s) {
+  private ChangeMessage message(ChangeContext ctx, CodeReviewCommit commit, CommitMergeStatus s)
+      throws AuthException, IOException, PermissionBackendException,
+          InvalidChangeOperationException {
     requireNonNull(s, "CommitMergeStatus may not be null");
     String txt = s.getDescription();
     if (s == CommitMergeStatus.CLEAN_MERGE) {
@@ -431,9 +437,16 @@
     }
   }
 
-  private ChangeMessage message(ChangeContext ctx, PatchSet.Id psId, String body) {
+  private ChangeMessage message(ChangeContext ctx, PatchSet.Id psId, String body)
+      throws AuthException, IOException, PermissionBackendException,
+          InvalidChangeOperationException {
+    stickyApprovalDiff = args.submitWithStickyApprovalDiff.apply(ctx.getNotes(), ctx.getUser());
     return ChangeMessagesUtil.newMessage(
-        psId, ctx.getUser(), ctx.getWhen(), body, ChangeMessagesUtil.TAG_MERGED);
+        psId,
+        ctx.getUser(),
+        ctx.getWhen(),
+        body + stickyApprovalDiff,
+        ChangeMessagesUtil.TAG_MERGED);
   }
 
   private void setMerged(ChangeContext ctx, ChangeMessage msg) {
@@ -441,7 +454,6 @@
     logger.atFine().log("Setting change %s merged", c.getId());
     c.setStatus(Change.Status.MERGED);
     c.setSubmissionId(args.submissionId.toString());
-
     // TODO(dborowitz): We need to be able to change the author of the message,
     // which is not the user from the update context. addMergedMessage was able
     // to do this in the past.
@@ -461,9 +473,12 @@
       // If we naively execute postUpdate even if the change is already merged when updateChange
       // being, then we are subject to a race where postUpdate steps are run twice if two submit
       // processes run at the same time.
-      logger.atFine().log("Skipping post-update steps for change %s", getId());
+      logger.atFine().log(
+          "Skipping post-update steps for change %s; submitter is %s", getId(), submitter);
       return;
     }
+    logger.atFine().log(
+        "Begin post-update steps for change %s; submitter is %s", getId(), submitter);
     postUpdateImpl(ctx);
 
     if (command != null) {
@@ -483,6 +498,9 @@
       }
     }
 
+    logger.atFine().log(
+        "Begin sending emails for submitting change %s; submitter is %s", getId(), submitter);
+
     // Assume the change must have been merged at this point, otherwise we would
     // have failed fast in one of the other steps.
     try {
@@ -492,7 +510,8 @@
               toMerge.change(),
               submitter.accountId(),
               ctx.getNotify(getId()),
-              ctx.getRepoView())
+              ctx.getRepoView(),
+              stickyApprovalDiff)
           .sendAsync();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email merged notification for %s", getId());
diff --git a/java/com/google/gerrit/server/update/RetryHelper.java b/java/com/google/gerrit/server/update/RetryHelper.java
index 2db625b..409c808 100644
--- a/java/com/google/gerrit/server/update/RetryHelper.java
+++ b/java/com/google/gerrit/server/update/RetryHelper.java
@@ -481,7 +481,7 @@
                   if (!traceContext.isTracing()) {
                     String traceId = "retry-on-failure-" + new RequestId();
                     traceContext.addTag(RequestId.Type.TRACE_ID, traceId).forceLogging();
-                    logger.atFine().withCause(t).log(
+                    logger.atWarning().withCause(t).log(
                         "AutoRetry: %s failed, retry with tracing enabled (cause = %s)",
                         actionName, cause);
                     opts.onAutoTrace().ifPresent(c -> c.accept(traceId));
@@ -492,7 +492,7 @@
                   // A non-recoverable failure occurred. We retried the operation with tracing
                   // enabled and it failed again. Log the failure so that admin can see if it
                   // differs from the failure that triggered the retry.
-                  logger.atFine().withCause(t).log(
+                  logger.atWarning().withCause(t).log(
                       "AutoRetry: auto-retry of %s has failed (cause = %s)", actionName, cause);
                   metrics.failuresOnAutoRetryCount.increment(actionType, actionName, cause);
                   return false;
@@ -504,7 +504,7 @@
       return executeWithTimeoutCount(actionType, action, opts, retryerBuilder.build(), listener);
     } finally {
       if (listener.getAttemptCount() > 1) {
-        logger.atFine().log("%s was attempted %d times", actionType, listener.getAttemptCount());
+        logger.atWarning().log("%s was attempted %d times", actionType, listener.getAttemptCount());
         metrics.attemptCounts.incrementBy(
             actionType,
             opts.actionName().orElse("N/A"),
diff --git a/java/com/google/gerrit/server/update/SubmissionExecutor.java b/java/com/google/gerrit/server/update/SubmissionExecutor.java
index 5a3a789..39eda58 100644
--- a/java/com/google/gerrit/server/update/SubmissionExecutor.java
+++ b/java/com/google/gerrit/server/update/SubmissionExecutor.java
@@ -17,7 +17,6 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.submit.MergeOpRepoManager;
-import java.util.Arrays;
 import java.util.Collection;
 import java.util.Optional;
 import java.util.stream.Collectors;
@@ -28,14 +27,9 @@
   private final boolean dryrun;
   private ImmutableList<BatchUpdateListener> additionalListeners = ImmutableList.of();
 
-  public SubmissionExecutor(
-      boolean dryrun, SubmissionListener listener, SubmissionListener... otherListeners) {
+  public SubmissionExecutor(boolean dryrun, ImmutableList<SubmissionListener> submissionListeners) {
     this.dryrun = dryrun;
-    this.submissionListeners =
-        ImmutableList.<SubmissionListener>builder()
-            .add(listener)
-            .addAll(Arrays.asList(otherListeners))
-            .build();
+    this.submissionListeners = submissionListeners;
     if (dryrun) {
       submissionListeners.forEach(SubmissionListener::setDryrun);
     }
diff --git a/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java b/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
index dffdff0..4c65c80 100644
--- a/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
+++ b/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.server.submit.MergeOpRepoManager;
 import com.google.gerrit.server.submit.SubmoduleOp;
 import com.google.inject.AbstractModule;
+import com.google.inject.Provides;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.Map;
@@ -39,11 +40,11 @@
   private boolean dryrun;
 
   public static class Module extends AbstractModule {
-    @Override
-    protected void configure() {
-      bind(SubmissionListener.class)
-          .annotatedWith(SuperprojectUpdateOnSubmission.class)
-          .to(SuperprojectUpdateSubmissionListener.class);
+    @Provides
+    @SuperprojectUpdateOnSubmission
+    ImmutableList<SubmissionListener> provideSubmissionListeners(
+        SuperprojectUpdateSubmissionListener listener) {
+      return ImmutableList.of(listener);
     }
   }
 
diff --git a/java/com/google/gerrit/server/util/AttentionSetUtil.java b/java/com/google/gerrit/server/util/AttentionSetUtil.java
index 62cad3f..26c862d 100644
--- a/java/com/google/gerrit/server/util/AttentionSetUtil.java
+++ b/java/com/google/gerrit/server/util/AttentionSetUtil.java
@@ -16,11 +16,16 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.AttentionSetUpdate.Operation;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import java.io.IOException;
 import java.util.Collection;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 /** Common helpers for dealing with attention set data structures. */
 public class AttentionSetUtil {
@@ -49,5 +54,45 @@
     }
   }
 
+  /**
+   * Returns the {@code Account.Id} of {@code user} if the user is active on the change, and exists.
+   * If the user doesn't exist or is not active on the change, the same exception is thrown to
+   * disallow probing for account existence based on exception type.
+   */
+  public static Account.Id resolveAccount(
+      AccountResolver accountResolver, ChangeNotes changeNotes, String user)
+      throws ConfigInvalidException, IOException, BadRequestException {
+    // We will throw this exception if the account doesn't exist, or if the account is not active.
+    // This is purposely the same exception so that users can't probe for account existence based on
+    // the thrown exception.
+    BadRequestException possibleExceptionForNotFoundOrInactiveAccount =
+        new BadRequestException(
+            String.format(
+                "%s doesn't exist or is not active on the change as an owner, uploader, "
+                    + "reviewer, or cc so they can't be added to the attention set",
+                user));
+    Account.Id attentionUserId;
+    try {
+      attentionUserId = accountResolver.resolveIgnoreVisibility(user).asUnique().account().id();
+    } catch (AccountResolver.UnresolvableAccountException ex) {
+      possibleExceptionForNotFoundOrInactiveAccount.initCause(ex);
+      throw possibleExceptionForNotFoundOrInactiveAccount;
+    }
+    if (!isActiveOnTheChange(changeNotes, attentionUserId)) {
+      throw possibleExceptionForNotFoundOrInactiveAccount;
+    }
+    return attentionUserId;
+  }
+
+  /**
+   * Returns whether {@code attentionUserId} is active on a change. Activity is defined as being a
+   * part of the reviewers, an uploader, or an owner of a change.
+   */
+  private static boolean isActiveOnTheChange(ChangeNotes changeNotes, Account.Id attentionUserId) {
+    return changeNotes.getChange().getOwner().equals(attentionUserId)
+        || changeNotes.getCurrentPatchSet().uploader().equals(attentionUserId)
+        || changeNotes.getReviewers().all().stream().anyMatch(id -> id.equals(attentionUserId));
+  }
+
   private AttentionSetUtil() {}
 }
diff --git a/java/com/google/gerrit/server/util/CommitMessageUtil.java b/java/com/google/gerrit/server/util/CommitMessageUtil.java
index 1c8ce0c..55e3951 100644
--- a/java/com/google/gerrit/server/util/CommitMessageUtil.java
+++ b/java/com/google/gerrit/server/util/CommitMessageUtil.java
@@ -22,13 +22,19 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import java.security.NoSuchAlgorithmException;
 import java.security.SecureRandom;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.util.ChangeIdUtil;
 
 /** Utility functions to manipulate commit messages. */
 public class CommitMessageUtil {
   private static final SecureRandom rng;
+  private static final Pattern changeIdFooterPattern =
+      Pattern.compile("Change-Id: *(I[a-f0-9]{40})");
 
   static {
     try {
@@ -71,6 +77,31 @@
   }
 
   public static Change.Key generateKey() {
-    return Change.key("I" + generateChangeId().name());
+    return Change.key(getChangeIdFromObjectId(generateChangeId()));
+  }
+
+  public static String getChangeIdFromObjectId(ObjectId objectId) {
+    return "I" + objectId.name();
+  }
+
+  /**
+   * Return the value of Change-Id from the commit message footer.
+   *
+   * <p>The behaviour matches {@link org.eclipse.jgit.util.ChangeIdUtil}. If more than one matching
+   * Change-Id footer is found, return the value of the last one.
+   *
+   * @param commitMessage commit message to get Change-Id from.
+   * @return {@link Optional} value of Change-Id footer in the commit message.
+   */
+  public static Optional<String> getChangeIdFromCommitMessageFooter(String commitMessage) {
+    int indexOfChangeId = ChangeIdUtil.indexOfChangeId(commitMessage, "\n");
+    if (indexOfChangeId == -1) {
+      return Optional.empty();
+    }
+    Matcher matcher = changeIdFooterPattern.matcher(commitMessage);
+    if (matcher.find(indexOfChangeId)) {
+      return Optional.of(matcher.group(1));
+    }
+    return Optional.empty();
   }
 }
diff --git a/java/com/google/gerrit/server/util/PluginLogFile.java b/java/com/google/gerrit/server/util/PluginLogFile.java
index de8b3aa..345e1b3 100644
--- a/java/com/google/gerrit/server/util/PluginLogFile.java
+++ b/java/com/google/gerrit/server/util/PluginLogFile.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.systemstatus.ServerInformation;
-import org.apache.log4j.AsyncAppender;
 import org.apache.log4j.Layout;
 import org.apache.log4j.LogManager;
 import org.apache.log4j.Logger;
@@ -38,10 +37,14 @@
 
   @Override
   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 (systemLog) {
+        if (logger.getAppender(logName) == null) {
+          logger.addAppender(systemLog.createAsyncAppender(logName, layout, true, true));
+        }
+      }
+    }
     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 85d9eb2..0ce6766 100644
--- a/java/com/google/gerrit/sshd/BaseCommand.java
+++ b/java/com/google/gerrit/sshd/BaseCommand.java
@@ -102,9 +102,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;
@@ -212,12 +212,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);
   }
 
   /**
@@ -227,13 +228,16 @@
    *
    * @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.setBean(options);
+    pluginOptions.startLifecycleListeners();
     pluginOptions.parseDynamicBeans(clp);
     pluginOptions.setDynamicBeans();
     pluginOptions.onBeanParseStart();
@@ -479,13 +483,16 @@
           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(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) {
@@ -548,7 +555,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/DispatchCommand.java b/java/com/google/gerrit/sshd/DispatchCommand.java
index 7db65bd..54171a3 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,8 @@
 
   @Override
   public void start(ChannelSession channel, Environment env) throws IOException {
-    try {
-      parseCommandLine();
+    try (DynamicOptions pluginOptions = new DynamicOptions(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..c94b25c 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,21 @@
   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(injector, dynamicBeans)) {
+            parseCommandLine(pluginOptions);
+            stdout = toPrintWriter(out);
+            stderr = toPrintWriter(err);
+            try (TraceContext traceContext = enableTracing();
+                PerformanceLogContext performanceLogContext =
+                    new PerformanceLogContext(config, performanceLoggers)) {
+              RequestInfo requestInfo =
+                  RequestInfo.builder(RequestInfo.RequestType.SSH, user, traceContext).build();
+              requestListeners.runEach(l -> l.onRequest(requestInfo));
+              SshCommand.this.run();
+            } finally {
+              stdout.flush();
+              stderr.flush();
+            }
           }
         },
         AccessPath.SSH_COMMAND);
diff --git a/java/com/google/gerrit/sshd/SshDaemon.java b/java/com/google/gerrit/sshd/SshDaemon.java
index c14ebd8..cd5a511 100644
--- a/java/com/google/gerrit/sshd/SshDaemon.java
+++ b/java/com/google/gerrit/sshd/SshDaemon.java
@@ -17,7 +17,15 @@
 import static com.google.gerrit.server.ssh.SshAddressesModule.IANA_SSH_PORT;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.SECONDS;
-import static org.apache.sshd.common.channel.ChannelOutputStream.WAIT_FOR_SPACE_TIMEOUT;
+import static org.apache.sshd.core.CoreModuleProperties.AUTH_TIMEOUT;
+import static org.apache.sshd.core.CoreModuleProperties.IDLE_TIMEOUT;
+import static org.apache.sshd.core.CoreModuleProperties.MAX_AUTH_REQUESTS;
+import static org.apache.sshd.core.CoreModuleProperties.MAX_CONCURRENT_SESSIONS;
+import static org.apache.sshd.core.CoreModuleProperties.NIO2_READ_TIMEOUT;
+import static org.apache.sshd.core.CoreModuleProperties.REKEY_BYTES_LIMIT;
+import static org.apache.sshd.core.CoreModuleProperties.REKEY_TIME_LIMIT;
+import static org.apache.sshd.core.CoreModuleProperties.SERVER_IDENTIFICATION;
+import static org.apache.sshd.core.CoreModuleProperties.WAIT_FOR_SPACE_TIMEOUT;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
@@ -44,24 +52,17 @@
 import java.net.InetSocketAddress;
 import java.net.SocketAddress;
 import java.net.UnknownHostException;
-import java.nio.file.FileStore;
-import java.nio.file.FileSystem;
-import java.nio.file.Path;
-import java.nio.file.PathMatcher;
-import java.nio.file.WatchService;
-import java.nio.file.attribute.UserPrincipalLookupService;
-import java.nio.file.spi.FileSystemProvider;
 import java.security.GeneralSecurityException;
 import java.security.InvalidKeyException;
 import java.security.KeyPair;
 import java.security.PublicKey;
+import java.time.Duration;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Iterator;
 import java.util.List;
-import java.util.Set;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.TimeUnit;
@@ -73,6 +74,7 @@
 import org.apache.sshd.common.cipher.Cipher;
 import org.apache.sshd.common.compression.BuiltinCompressions;
 import org.apache.sshd.common.compression.Compression;
+import org.apache.sshd.common.file.nonefs.NoneFileSystemFactory;
 import org.apache.sshd.common.forward.DefaultForwarderFactory;
 import org.apache.sshd.common.future.CloseFuture;
 import org.apache.sshd.common.future.SshFutureListener;
@@ -81,8 +83,6 @@
 import org.apache.sshd.common.io.IoServiceFactory;
 import org.apache.sshd.common.io.IoServiceFactoryFactory;
 import org.apache.sshd.common.io.IoSession;
-import org.apache.sshd.common.io.mina.MinaServiceFactoryFactory;
-import org.apache.sshd.common.io.mina.MinaSession;
 import org.apache.sshd.common.io.nio2.Nio2ServiceFactoryFactory;
 import org.apache.sshd.common.kex.KeyExchangeFactory;
 import org.apache.sshd.common.keyprovider.KeyPairProvider;
@@ -96,6 +96,8 @@
 import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
 import org.apache.sshd.common.util.net.SshdSocketAddress;
 import org.apache.sshd.common.util.security.SecurityUtils;
+import org.apache.sshd.mina.MinaServiceFactoryFactory;
+import org.apache.sshd.mina.MinaSession;
 import org.apache.sshd.server.ServerBuilder;
 import org.apache.sshd.server.SshServer;
 import org.apache.sshd.server.auth.UserAuthFactory;
@@ -169,45 +171,38 @@
     this.advertised = advertised;
     keepAlive = cfg.getBoolean("sshd", "tcpkeepalive", true);
 
-    getProperties()
-        .put(
-            SERVER_IDENTIFICATION,
-            "GerritCodeReview_"
-                + Version.getVersion() //
-                + " ("
-                + super.getVersion()
-                + ")");
-
-    getProperties().put(MAX_AUTH_REQUESTS, String.valueOf(cfg.getInt("sshd", "maxAuthTries", 6)));
-
-    getProperties()
-        .put(
-            AUTH_TIMEOUT,
-            String.valueOf(
-                MILLISECONDS.convert(
-                    ConfigUtil.getTimeUnit(cfg, "sshd", null, "loginGraceTime", 120, SECONDS),
-                    SECONDS)));
+    SERVER_IDENTIFICATION.set(
+        this,
+        "GerritCodeReview_"
+            + Version.getVersion() //
+            + " ("
+            + super.getVersion()
+            + ")");
+    MAX_AUTH_REQUESTS.set(this, cfg.getInt("sshd", "maxAuthTries", 6));
+    AUTH_TIMEOUT.set(
+        this,
+        Duration.ofSeconds(
+            MILLISECONDS.convert(
+                ConfigUtil.getTimeUnit(cfg, "sshd", null, "loginGraceTime", 120, SECONDS),
+                SECONDS)));
 
     long idleTimeoutSeconds = ConfigUtil.getTimeUnit(cfg, "sshd", null, "idleTimeout", 0, SECONDS);
-    getProperties().put(IDLE_TIMEOUT, String.valueOf(SECONDS.toMillis(idleTimeoutSeconds)));
-    getProperties().put(NIO2_READ_TIMEOUT, String.valueOf(SECONDS.toMillis(idleTimeoutSeconds)));
+    IDLE_TIMEOUT.set(this, Duration.ofSeconds(SECONDS.toMillis(idleTimeoutSeconds)));
+    NIO2_READ_TIMEOUT.set(this, Duration.ofSeconds(SECONDS.toMillis(idleTimeoutSeconds)));
 
     long rekeyTimeLimit =
         ConfigUtil.getTimeUnit(cfg, "sshd", null, "rekeyTimeLimit", 3600, SECONDS);
-    getProperties().put(REKEY_TIME_LIMIT, String.valueOf(SECONDS.toMillis(rekeyTimeLimit)));
+    REKEY_TIME_LIMIT.set(this, Duration.ofSeconds(SECONDS.toMillis(rekeyTimeLimit)));
 
-    getProperties()
-        .put(
-            REKEY_BYTES_LIMIT,
-            String.valueOf(cfg.getLong("sshd", "rekeyBytesLimit", 1024 * 1024 * 1024 /* 1GB */)));
+    REKEY_BYTES_LIMIT.set(
+        this, cfg.getLong("sshd", "rekeyBytesLimit", 1024 * 1024 * 1024 /* 1GB */));
 
     long waitTimeoutSeconds = ConfigUtil.getTimeUnit(cfg, "sshd", null, "waitTimeout", 30, SECONDS);
-    getProperties()
-        .put(WAIT_FOR_SPACE_TIMEOUT, String.valueOf(SECONDS.toMillis(waitTimeoutSeconds)));
+    WAIT_FOR_SPACE_TIMEOUT.set(this, Duration.ofSeconds(SECONDS.toMillis(waitTimeoutSeconds)));
 
     final int maxConnectionsPerUser = cfg.getInt("sshd", "maxConnectionsPerUser", 64);
     if (0 < maxConnectionsPerUser) {
-      getProperties().put(MAX_CONCURRENT_SESSIONS, String.valueOf(maxConnectionsPerUser));
+      MAX_CONCURRENT_SESSIONS.set(this, maxConnectionsPerUser);
     }
 
     final String kerberosKeytab = cfg.getString("sshd", null, "kerberosKeytab");
@@ -772,66 +767,6 @@
   }
 
   private void initFileSystemFactory() {
-    setFileSystemFactory(
-        session ->
-            new FileSystem() {
-              @Override
-              public void close() throws IOException {}
-
-              @Override
-              public Iterable<FileStore> getFileStores() {
-                return null;
-              }
-
-              @Override
-              public Path getPath(String arg0, String... arg1) {
-                return null;
-              }
-
-              @Override
-              public PathMatcher getPathMatcher(String arg0) {
-                return null;
-              }
-
-              @Override
-              public Iterable<Path> getRootDirectories() {
-                return null;
-              }
-
-              @Override
-              public String getSeparator() {
-                return null;
-              }
-
-              @Override
-              public UserPrincipalLookupService getUserPrincipalLookupService() {
-                return null;
-              }
-
-              @Override
-              public boolean isOpen() {
-                return false;
-              }
-
-              @Override
-              public boolean isReadOnly() {
-                return false;
-              }
-
-              @Override
-              public WatchService newWatchService() throws IOException {
-                return null;
-              }
-
-              @Override
-              public FileSystemProvider provider() {
-                return null;
-              }
-
-              @Override
-              public Set<String> supportedFileAttributeViews() {
-                return null;
-              }
-            });
+    setFileSystemFactory(NoneFileSystemFactory.INSTANCE);
   }
 }
diff --git a/java/com/google/gerrit/sshd/SuExec.java b/java/com/google/gerrit/sshd/SuExec.java
index ea163d5..3c6e8c2 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(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/SetTopicCommand.java b/java/com/google/gerrit/sshd/commands/SetTopicCommand.java
index 70700f1..35cb3ba 100644
--- a/java/com/google/gerrit/sshd/commands/SetTopicCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetTopicCommand.java
@@ -16,12 +16,11 @@
 
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.api.changes.TopicInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.SetTopicOp;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.restapi.change.SetTopicOp;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.sshd.ChangeArgumentParser;
@@ -74,18 +73,17 @@
 
   @Override
   public void run() throws Exception {
-    TopicInput input = new TopicInput();
     if (topic != null) {
-      input.topic = topic.trim();
+      topic = topic.trim();
     }
 
-    if (input.topic != null && input.topic.length() > ChangeUtil.TOPIC_MAX_LENGTH) {
+    if (topic != null && topic.length() > ChangeUtil.TOPIC_MAX_LENGTH) {
       throw new BadRequestException(
           String.format("topic length exceeds the limit (%s)", ChangeUtil.TOPIC_MAX_LENGTH));
     }
 
     for (ChangeResource r : changes.values()) {
-      SetTopicOp op = topicOpFactory.create(input);
+      SetTopicOp op = topicOpFactory.create(topic);
       try (BatchUpdate u =
           updateFactory.create(r.getChange().getProject(), user, TimeUtil.nowTs())) {
         u.addOp(r.getId(), op);
diff --git a/java/com/google/gerrit/sshd/commands/ShowCaches.java b/java/com/google/gerrit/sshd/commands/ShowCaches.java
index ba84179..979be1b 100644
--- a/java/com/google/gerrit/sshd/commands/ShowCaches.java
+++ b/java/com/google/gerrit/sshd/commands/ShowCaches.java
@@ -49,7 +49,7 @@
 import java.util.Map;
 import org.apache.sshd.common.io.IoAcceptor;
 import org.apache.sshd.common.io.IoSession;
-import org.apache.sshd.common.io.mina.MinaSession;
+import org.apache.sshd.mina.MinaSession;
 import org.apache.sshd.server.Environment;
 import org.apache.sshd.server.channel.ChannelSession;
 import org.kohsuke.args4j.Option;
diff --git a/java/com/google/gerrit/sshd/commands/ShowConnections.java b/java/com/google/gerrit/sshd/commands/ShowConnections.java
index d271364..7eeb770 100644
--- a/java/com/google/gerrit/sshd/commands/ShowConnections.java
+++ b/java/com/google/gerrit/sshd/commands/ShowConnections.java
@@ -39,10 +39,10 @@
 import java.util.Optional;
 import org.apache.sshd.common.io.IoAcceptor;
 import org.apache.sshd.common.io.IoSession;
-import org.apache.sshd.common.io.mina.MinaAcceptor;
-import org.apache.sshd.common.io.mina.MinaSession;
 import org.apache.sshd.common.io.nio2.Nio2Acceptor;
 import org.apache.sshd.common.session.helpers.AbstractSession;
+import org.apache.sshd.mina.MinaAcceptor;
+import org.apache.sshd.mina.MinaSession;
 import org.apache.sshd.server.Environment;
 import org.apache.sshd.server.channel.ChannelSession;
 import org.kohsuke.args4j.Option;
diff --git a/java/com/google/gerrit/sshd/commands/StreamEvents.java b/java/com/google/gerrit/sshd/commands/StreamEvents.java
index 45540a0..c47d24c 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,62 @@
 
   @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(injector, dynamicBeans)) {
+      try {
+        parseCommandLine(pluginOptions);
+      } catch (UnloggedFailure e) {
+        String msg = e.getMessage();
+        if (!msg.endsWith("\n")) {
+          msg += "\n";
+        }
+        err.write(msg.getBytes(UTF_8));
+        err.flush();
+        onExit(1);
+        return;
       }
-      err.write(msg.getBytes(UTF_8));
-      err.flush();
-      onExit(1);
-      return;
-    }
 
-    PrintWriter stdout = toPrintWriter(out);
-    CancelableRunnable writer =
-        new CancelableRunnable() {
-          @Override
-          public void run() {
-            writeEvents(this, stdout);
-          }
-
-          @Override
-          public void cancel() {
-            onExit(0);
-          }
-
-          @Override
-          public String toString() {
-            StringBuilder b = new StringBuilder();
-            b.append("Stream Events");
-            if (currentUser.getUserName().isPresent()) {
-              b.append(" (").append(currentUser.getUserName().get()).append(")");
+      PrintWriter stdout = toPrintWriter(out);
+      CancelableRunnable writer =
+          new CancelableRunnable() {
+            @Override
+            public void run() {
+              writeEvents(this, stdout);
             }
-            return b.toString();
-          }
-        };
 
-    eventListenerRegistration =
-        eventListeners.add(
-            "gerrit",
-            new UserScopedEventListener() {
-              @Override
-              public void onEvent(Event event) {
-                if (subscribedToEvents.isEmpty() || subscribedToEvents.contains(event.getType())) {
-                  offer(writer, event);
+            @Override
+            public void cancel() {
+              onExit(0);
+            }
+
+            @Override
+            public String toString() {
+              StringBuilder b = new StringBuilder();
+              b.append("Stream Events");
+              if (currentUser.getUserName().isPresent()) {
+                b.append(" (").append(currentUser.getUserName().get()).append(")");
+              }
+              return b.toString();
+            }
+          };
+
+      eventListenerRegistration =
+          eventListeners.add(
+              "gerrit",
+              new UserScopedEventListener() {
+                @Override
+                public void onEvent(Event event) {
+                  if (subscribedToEvents.isEmpty()
+                      || subscribedToEvents.contains(event.getType())) {
+                    offer(writer, event);
+                  }
                 }
-              }
 
-              @Override
-              public CurrentUser getUser() {
-                return currentUser;
-              }
-            });
+                @Override
+                public CurrentUser getUser() {
+                  return currentUser;
+                }
+              });
+    }
   }
 
   private void removeEventListenerRegistration() {
diff --git a/java/com/google/gerrit/sshd/commands/UploadArchive.java b/java/com/google/gerrit/sshd/commands/UploadArchive.java
index 67dc5a5..0eda433 100644
--- a/java/com/google/gerrit/sshd/commands/UploadArchive.java
+++ b/java/com/google/gerrit/sshd/commands/UploadArchive.java
@@ -73,6 +73,14 @@
     @Option(name = "--prefix", usage = "Prepend <prefix>/ to each filename in the archive.")
     private String prefix;
 
+    @Option(
+        name = "--compression-level",
+        usage =
+            "Controls compression for different formats. The value is in [0-9] with 0 for fast levels"
+                + " with medium compressions, and 9 for the highest compression. Note that higher"
+                + " compressions require more memory.")
+    private int compressionLevel = -1;
+
     @Option(name = "-0", usage = "Store the files instead of deflating them.")
     private boolean level0;
 
@@ -223,6 +231,9 @@
   }
 
   private Map<String, Object> getFormatOptions(ArchiveFormatInternal f) {
+    if (options.compressionLevel != -1) {
+      return ImmutableMap.of("compression-level", options.compressionLevel);
+    }
     if (f == ArchiveFormatInternal.ZIP) {
       int value =
           Arrays.asList(
diff --git a/java/com/google/gerrit/testing/BUILD b/java/com/google/gerrit/testing/BUILD
index 16c15ad..93c996e8 100644
--- a/java/com/google/gerrit/testing/BUILD
+++ b/java/com/google/gerrit/testing/BUILD
@@ -15,11 +15,13 @@
     deps = [
         "//java/com/google/gerrit/acceptance/config",
         "//java/com/google/gerrit/acceptance/testsuite/project",
+        "//java/com/google/gerrit/auth",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/gpg",
+        "//java/com/google/gerrit/httpd/auth/restapi",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index/project",
         "//java/com/google/gerrit/lifecycle",
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index 1779a18..1c322b2 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -22,10 +22,12 @@
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperationsImpl;
+import com.google.gerrit.auth.AuthModule;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.systemstatus.ServerInformation;
 import com.google.gerrit.gpg.GpgModule;
+import com.google.gerrit.httpd.auth.restapi.OAuthRestModule;
 import com.google.gerrit.index.IndexType;
 import com.google.gerrit.index.SchemaDefinitions;
 import com.google.gerrit.index.project.ProjectSchemaDefinitions;
@@ -41,12 +43,14 @@
 import com.google.gerrit.server.audit.AuditModule;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
+import com.google.gerrit.server.change.FileInfoJsonModule;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.AnonymousCowardNameProvider;
+import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.CanonicalWebUrlModule;
 import com.google.gerrit.server.config.CanonicalWebUrlProvider;
 import com.google.gerrit.server.config.DefaultUrlFormatter;
@@ -170,6 +174,9 @@
     bind(GerritRuntime.class).toInstance(GerritRuntime.DAEMON);
     bind(MetricMaker.class).to(DisabledMetricMaker.class);
     install(cfgInjector.getInstance(GerritGlobalModule.class));
+
+    AuthConfig authConfig = cfgInjector.getInstance(AuthConfig.class);
+    install(new AuthModule(authConfig));
     install(new GerritApiModule());
     factory(PluginUser.Factory.class);
     install(new PluginApiModule());
@@ -239,7 +246,9 @@
     bind(ServerInformationImpl.class);
     bind(ServerInformation.class).to(ServerInformationImpl.class);
     install(new RestApiModule());
+    install(new OAuthRestModule());
     install(new DefaultProjectNameLockManager.Module());
+    install(new FileInfoJsonModule(cfg));
 
     bind(ProjectOperations.class).to(ProjectOperationsImpl.class);
   }
diff --git a/java/com/google/gerrit/util/cli/CmdLineParser.java b/java/com/google/gerrit/util/cli/CmdLineParser.java
index 162f324..c374691 100644
--- a/java/com/google/gerrit/util/cli/CmdLineParser.java
+++ b/java/com/google/gerrit/util/cli/CmdLineParser.java
@@ -562,20 +562,22 @@
      *
      * @param name name
      * @return the {@code OptionHandler} or {@code null}
-     *     <p>Note: this is cut & pasted from the parent class in arg4j, it was private and it
-     *     needed to be exposed.
+     *     <p>Note: this was originally cut & pasted from the parent class in arg4j, it was private
+     *     and it needed to be exposed.
      */
     @SuppressWarnings("rawtypes")
     public OptionHandler findOptionByName(String name) {
       for (OptionHandler h : optionsList) {
-        NamedOptionDef option = (NamedOptionDef) h.option;
-        if (name.equals(option.name())) {
-          return h;
-        }
-        for (String alias : option.aliases()) {
-          if (name.equals(alias)) {
+        if (h.option instanceof NamedOptionDef) {
+          NamedOptionDef option = (NamedOptionDef) h.option;
+          if (name.equals(option.name())) {
             return h;
           }
+          for (String alias : option.aliases()) {
+            if (name.equals(alias)) {
+              return h;
+            }
+          }
         }
       }
       return null;
diff --git a/java/gerrit/BUILD b/java/gerrit/BUILD
index 7dbf751..db831b7 100644
--- a/java/gerrit/BUILD
+++ b/java/gerrit/BUILD
@@ -5,7 +5,6 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
-        "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server",
diff --git a/java/com/google/gerrit/acceptance/WaitUtilTest.java b/javatests/com/google/gerrit/acceptance/WaitUtilTest.java
similarity index 100%
rename from java/com/google/gerrit/acceptance/WaitUtilTest.java
rename to javatests/com/google/gerrit/acceptance/WaitUtilTest.java
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 0c30ef5..50b7a7c 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -47,6 +47,7 @@
 import static com.google.gerrit.extensions.client.ReviewerState.CC;
 import static com.google.gerrit.extensions.client.ReviewerState.REMOVED;
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static com.google.gerrit.server.StarredChangesUtil.DEFAULT_LABEL;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
@@ -91,8 +92,10 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Permission;
@@ -141,8 +144,10 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
+import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -154,6 +159,7 @@
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.testing.TestChangeETagComputation;
+import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
@@ -176,6 +182,7 @@
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.name.Named;
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
@@ -611,7 +618,7 @@
     ReviewInput in =
         ReviewInput.approve()
             .reviewer(user.email())
-            .label("Code-Review", 1)
+            .label(LabelId.CODE_REVIEW, 1)
             .setWorkInProgress(true);
     gApi.changes().id(r.getChangeId()).current().review(in);
 
@@ -619,7 +626,8 @@
     assertThat(info.workInProgress).isTrue();
     assertThat(info.reviewers.get(REVIEWER).stream().map(ai -> ai._accountId).collect(toList()))
         .containsExactly(admin.id().get(), user.id().get());
-    assertThat(info.labels.get("Code-Review").recommended._accountId).isEqualTo(admin.id().get());
+    assertThat(info.labels.get(LabelId.CODE_REVIEW).recommended._accountId)
+        .isEqualTo(admin.id().get());
   }
 
   @Test
@@ -774,7 +782,7 @@
     assertThat(description).isEqualTo("Rebase");
 
     // ...and the approval was copied
-    LabelInfo cr = c2.labels.get("Code-Review");
+    LabelInfo cr = c2.labels.get(LabelId.CODE_REVIEW);
     assertThat(cr).isNotNull();
     assertThat(cr.all).hasSize(1);
     assertThat(cr.all.get(0).value).isEqualTo(1);
@@ -1338,9 +1346,135 @@
             "If09d8782c1e59dd0b33de2b1ec3595d69cc10ad5");
     PushOneCommit.Result r2 = push.to("refs/for/master");
     r2.assertOkStatus();
-    assertThrows(
-        ResourceConflictException.class,
-        () -> gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase());
+    ResourceConflictException exception =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "The change could not be rebased due to a conflict during merge.\n\n"
+                    + "merge conflict(s):\n%s",
+                PushOneCommit.FILE_NAME));
+  }
+
+  @Test
+  public void rebaseDoesNotAddWorkInProgress() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // create an unrelated change so that we can rebase
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result unrelated = createChange();
+    gApi.changes().id(unrelated.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(unrelated.getChangeId()).current().submit();
+
+    gApi.changes().id(r.getChangeId()).rebase();
+
+    // change is still ready for review after rebase
+    assertThat(gApi.changes().id(r.getChangeId()).get().workInProgress).isNull();
+  }
+
+  @Test
+  public void rebaseDoesNotRemoveWorkInProgress() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).setWorkInProgress();
+
+    // create an unrelated change so that we can rebase
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result unrelated = createChange();
+    gApi.changes().id(unrelated.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(unrelated.getChangeId()).current().submit();
+
+    gApi.changes().id(r.getChangeId()).rebase();
+
+    // change is still work in progress after rebase
+    assertThat(gApi.changes().id(r.getChangeId()).get().workInProgress).isTrue();
+  }
+
+  @Test
+  public void rebaseConflict_conflictsAllowed() throws Exception {
+    String patchSetSubject = "patch set change";
+    String patchSetContent = "patch set content";
+    String baseSubject = "base change";
+    String baseContent = "base content";
+
+    PushOneCommit.Result r1 = createChange(baseSubject, PushOneCommit.FILE_NAME, baseContent);
+    gApi.changes()
+        .id(r1.getChangeId())
+        .revision(r1.getCommit().name())
+        .review(ReviewInput.approve());
+    gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
+
+    testRepo.reset("HEAD~1");
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(), testRepo, patchSetSubject, PushOneCommit.FILE_NAME, patchSetContent);
+    PushOneCommit.Result r2 = push.to("refs/for/master");
+    r2.assertOkStatus();
+
+    String changeId = r2.getChangeId();
+    RevCommit patchSet = r2.getCommit();
+    RevCommit base = r1.getCommit();
+
+    TestWorkInProgressStateChangedListener wipStateChangedListener =
+        new TestWorkInProgressStateChangedListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(wipStateChangedListener)) {
+      RebaseInput rebaseInput = new RebaseInput();
+      rebaseInput.allowConflicts = true;
+      ChangeInfo changeInfo =
+          gApi.changes().id(changeId).revision(patchSet.name()).rebaseAsInfo(rebaseInput);
+      assertThat(changeInfo.containsGitConflicts).isTrue();
+      assertThat(changeInfo.workInProgress).isTrue();
+    }
+    assertThat(wipStateChangedListener.invoked).isTrue();
+    assertThat(wipStateChangedListener.wip).isTrue();
+
+    // To get the revisions, we must retrieve the change with more change options.
+    ChangeInfo changeInfo =
+        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+    assertThat(changeInfo.revisions).hasSize(2);
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+        .isEqualTo(base.name());
+
+    // Verify that the file content in the created patch set is correct.
+    // We expect that it has conflict markers to indicate the conflict.
+    BinaryResult bin =
+        gApi.changes().id(changeId).current().file(PushOneCommit.FILE_NAME).content();
+    ByteArrayOutputStream os = new ByteArrayOutputStream();
+    bin.writeTo(os);
+    String fileContent = new String(os.toByteArray(), UTF_8);
+    String patchSetSha1 = abbreviateName(patchSet, 6);
+    String baseSha1 = abbreviateName(base, 6);
+    assertThat(fileContent)
+        .isEqualTo(
+            "<<<<<<< PATCH SET ("
+                + patchSetSha1
+                + " "
+                + patchSetSubject
+                + ")\n"
+                + patchSetContent
+                + "\n"
+                + "=======\n"
+                + baseContent
+                + "\n"
+                + ">>>>>>> BASE      ("
+                + baseSha1
+                + " "
+                + baseSubject
+                + ")\n");
+
+    // Verify the message that has been posted on the change.
+    List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
+    assertThat(messages).hasSize(2);
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            "Patch Set 2: Patch Set 1 was rebased\n\n"
+                + "The following files contain Git conflicts:\n"
+                + "* "
+                + PushOneCommit.FILE_NAME
+                + "\n");
   }
 
   @Test
@@ -1504,19 +1638,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());
   }
@@ -2193,7 +2327,7 @@
         gApi.changes().id(r.getChangeId()).reviewer(admin.id().toString()).votes();
 
     assertThat(m).hasSize(1);
-    assertThat(m).containsExactly("Code-Review", Short.valueOf((short) 2));
+    assertThat(m).containsExactly(LabelId.CODE_REVIEW, Short.valueOf((short) 2));
 
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.dislike());
@@ -2201,7 +2335,7 @@
     m = gApi.changes().id(r.getChangeId()).reviewer(user.id().toString()).votes();
 
     assertThat(m).hasSize(1);
-    assertThat(m).containsExactly("Code-Review", Short.valueOf((short) -1));
+    assertThat(m).containsExactly(LabelId.CODE_REVIEW, Short.valueOf((short) -1));
   }
 
   @Test
@@ -2215,18 +2349,18 @@
     // check finding by address works
     Map<String, Short> m = gApi.changes().id(r.getChangeId()).reviewer(admin.email()).votes();
     assertThat(m).hasSize(1);
-    assertThat(m).containsEntry("Code-Review", Short.valueOf((short) 2));
+    assertThat(m).containsEntry(LabelId.CODE_REVIEW, Short.valueOf((short) 2));
 
     // check finding by id works
     m = gApi.changes().id(r.getChangeId()).reviewer(admin.id().toString()).votes();
     assertThat(m).hasSize(1);
-    assertThat(m).containsEntry("Code-Review", Short.valueOf((short) 2));
+    assertThat(m).containsEntry(LabelId.CODE_REVIEW, Short.valueOf((short) 2));
   }
 
   @Test
   public void removeReviewerNoVotes() throws Exception {
     LabelType verified =
-        label("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+        label(LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().upsertLabelType(verified);
       u.save();
@@ -2258,6 +2392,10 @@
     assertThat(message.body()).contains("Removed reviewer " + user.fullName() + ".");
     assertThat(message.body()).doesNotContain("with the following votes");
 
+    // Make sure the change message for removing a reviewer is correct.
+    assertThat(Iterables.getLast(gApi.changes().id(changeId).messages()).message)
+        .contains("Removed reviewer " + user.fullName());
+
     // Make sure the reviewer can still be added again.
     gApi.changes().id(changeId).addReviewer(user.id().toString());
     c = gApi.changes().id(changeId).get();
@@ -2273,6 +2411,31 @@
   }
 
   @Test
+  public void removeCC() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    // Add a cc
+    AddReviewerInput addReviewerInput = new AddReviewerInput();
+    addReviewerInput.state = CC;
+    addReviewerInput.reviewer = user.id().toString();
+    gApi.changes().id(changeId).addReviewer(addReviewerInput);
+
+    // Remove a cc
+    sender.clear();
+    gApi.changes().id(changeId).reviewer(user.id().toString()).remove();
+    assertThat(gApi.changes().id(changeId).get().reviewers).isEmpty();
+
+    // Make sure the email for removing a cc is correct.
+    assertThat(sender.getMessages()).hasSize(1);
+    Message message = sender.getMessages().get(0);
+    assertThat(message.body()).contains("Removed cc " + user.fullName() + ".");
+
+    // Make sure the change message for removing a reviewer is correct.
+    assertThat(Iterables.getLast(gApi.changes().id(changeId).messages()).message)
+        .contains("Removed cc " + user.fullName());
+  }
+
+  @Test
   public void removeReviewer() throws Exception {
     testRemoveReviewer(true);
   }
@@ -2403,7 +2566,10 @@
 
     requestScopeOperations.setApiUser(admin.id());
     sender.clear();
-    gApi.changes().id(r.getChangeId()).reviewer(user.id().toString()).deleteVote("Code-Review");
+    gApi.changes()
+        .id(r.getChangeId())
+        .reviewer(user.id().toString())
+        .deleteVote(LabelId.CODE_REVIEW);
 
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
@@ -2417,7 +2583,7 @@
         gApi.changes().id(r.getChangeId()).reviewer(user.id().toString()).votes();
 
     // Dummy 0 approval on the change to block vote copying to this patch set.
-    assertThat(m).containsExactly("Code-Review", Short.valueOf((short) 0));
+    assertThat(m).containsExactly(LabelId.CODE_REVIEW, Short.valueOf((short) 0));
 
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
 
@@ -2439,7 +2605,7 @@
     requestScopeOperations.setApiUser(admin.id());
     sender.clear();
     DeleteVoteInput in = new DeleteVoteInput();
-    in.label = "Code-Review";
+    in.label = LabelId.CODE_REVIEW;
     in.notify = NotifyHandling.NONE;
     gApi.changes().id(r.getChangeId()).reviewer(user.id().toString()).deleteVote(in);
     assertThat(sender.getMessages()).isEmpty();
@@ -2451,7 +2617,7 @@
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
 
     DeleteVoteInput in = new DeleteVoteInput();
-    in.label = "Code-Review";
+    in.label = LabelId.CODE_REVIEW;
     in.notify = NotifyHandling.NONE;
 
     // notify unrelated account as TO
@@ -2505,14 +2671,14 @@
                 gApi.changes()
                     .id(r.getChangeId())
                     .reviewer(admin.id().toString())
-                    .deleteVote("Code-Review"));
+                    .deleteVote(LabelId.CODE_REVIEW));
     assertThat(thrown).hasMessageThat().contains("delete vote not permitted");
   }
 
   @Test
   public void nonVotingReviewerStaysAfterSubmit() throws Exception {
     LabelType verified =
-        label("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+        label(LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
     String heads = "refs/heads/*";
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().upsertLabelType(verified);
@@ -2522,7 +2688,7 @@
         .project(project)
         .forUpdate()
         .add(allowLabel(verified.getName()).ref(heads).group(CHANGE_OWNER).range(-1, 1))
-        .add(allowLabel("Code-Review").ref(heads).group(REGISTERED_USERS).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref(heads).group(REGISTERED_USERS).range(-2, +2))
         .update();
 
     // Set Code-Review+2 and Verified+1 as admin (change owner)
@@ -2671,7 +2837,7 @@
                 .withOptions(
                     ALL_REVISIONS, CHANGE_ACTIONS, CURRENT_ACTIONS, DETAILED_LABELS, MESSAGES)
                 .get());
-    assertThat(Iterables.getOnlyElement(result.labels.keySet())).isEqualTo("Code-Review");
+    assertThat(Iterables.getOnlyElement(result.labels.keySet())).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(result.messages).hasSize(1);
     assertThat(result.actions).isNotEmpty();
 
@@ -2854,7 +3020,7 @@
   @Test
   public void commitFooters() throws Exception {
     LabelType verified =
-        label("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+        label(LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
     LabelType custom1 =
         label("Custom1", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
     LabelType custom2 =
@@ -2882,8 +3048,8 @@
     r2.assertOkStatus();
 
     ReviewInput in = new ReviewInput();
-    in.label("Code-Review", 1);
-    in.label("Verified", 1);
+    in.label(LabelId.CODE_REVIEW, 1);
+    in.label(LabelId.VERIFIED, 1);
     in.label("Custom1", -1);
     in.label("Custom2", 1);
     gApi.changes().id(r2.getChangeId()).current().review(in);
@@ -2922,14 +3088,19 @@
   public void customCommitFooters() throws Exception {
     PushOneCommit.Result change = createChange();
     ChangeInfo actual;
-    try (Registration registration =
-        extensionRegistry
-            .newRegistration()
-            .add(
-                (newCommitMessage, original, mergeTip, destination) -> {
-                  assertThat(original.getName()).isNotEqualTo(mergeTip.getName());
-                  return newCommitMessage + "Custom: " + destination.branch();
-                })) {
+    ChangeMessageModifier link =
+        new ChangeMessageModifier() {
+          @Override
+          public String onSubmit(
+              String newCommitMessage,
+              RevCommit original,
+              RevCommit mergeTip,
+              BranchNameKey destination) {
+            assertThat(original.getName()).isNotEqualTo(mergeTip.getName());
+            return newCommitMessage + "Custom: " + destination.branch();
+          }
+        };
+    try (Registration registration = extensionRegistry.newRegistration().add(link)) {
       actual = gApi.changes().id(change.getChangeId()).get(ALL_REVISIONS, COMMIT_FOOTERS);
     }
     List<String> footers =
@@ -2986,7 +3157,7 @@
     String triplet = project.get() + "~master~" + r.getChangeId();
     gApi.changes().id(triplet).addReviewer(user.username());
     ChangeInfo c = gApi.changes().id(triplet).get(DETAILED_LABELS);
-    LabelInfo codeReview = c.labels.get("Code-Review");
+    LabelInfo codeReview = c.labels.get(LabelId.CODE_REVIEW);
     assertThat(codeReview.all).hasSize(1);
     ApprovalInfo approval = codeReview.all.get(0);
     assertThat(approval._accountId).isEqualTo(user.id().get());
@@ -2995,11 +3166,15 @@
     projectOperations
         .project(project)
         .forUpdate()
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-1, 1))
+        .add(
+            blockLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
         .update();
 
     c = gApi.changes().id(triplet).get(DETAILED_LABELS);
-    codeReview = c.labels.get("Code-Review");
+    codeReview = c.labels.get(LabelId.CODE_REVIEW);
     assertThat(codeReview.all).hasSize(1);
     approval = codeReview.all.get(0);
     assertThat(approval._accountId).isEqualTo(user.id().get());
@@ -3190,8 +3365,8 @@
     PushOneCommit.Result r = createChange();
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.status).isEqualTo(ChangeStatus.NEW);
-    assertThat(change.labels.keySet()).containsExactly("Code-Review");
-    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
 
     // add new label and assert that it's returned for existing changes
     AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
@@ -3209,10 +3384,11 @@
         .update();
 
     change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.labels.keySet()).containsExactly("Code-Review", "Verified");
-    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review", "Verified");
-    assertPermitted(change, "Code-Review", -2, -1, 0, 1, 2);
-    assertPermitted(change, "Verified", -1, 0, 1);
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW, LabelId.VERIFIED);
+    assertThat(change.permittedLabels.keySet())
+        .containsExactly(LabelId.CODE_REVIEW, LabelId.VERIFIED);
+    assertPermitted(change, LabelId.CODE_REVIEW, -2, -1, 0, 1, 2);
+    assertPermitted(change, LabelId.VERIFIED, -1, 0, 1);
 
     // add an approval on the new label
     gApi.changes()
@@ -3236,15 +3412,15 @@
         .update();
 
     change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.labels.keySet()).containsExactly("Code-Review");
-    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
 
     // abandon the change and see that the returned labels stay the same
     // while all permitted labels disappear.
     gApi.changes().id(r.getChangeId()).abandon();
     change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.status).isEqualTo(ChangeStatus.ABANDONED);
-    assertThat(change.labels.keySet()).containsExactly("Code-Review");
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
     assertThat(change.permittedLabels).isEmpty();
   }
 
@@ -3257,9 +3433,9 @@
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
     assertThat(change.submissionId).isNotNull();
-    assertThat(change.labels.keySet()).containsExactly("Code-Review");
-    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
-    assertPermitted(change, "Code-Review", 2);
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertPermitted(change, LabelId.CODE_REVIEW, 2);
 
     LabelType verified = TestLabels.verified();
     AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
@@ -3277,10 +3453,11 @@
         .update();
 
     change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.labels.keySet()).containsExactly("Code-Review", "Verified");
-    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review", "Verified");
-    assertPermitted(change, "Code-Review", 2);
-    assertPermitted(change, "Verified", 0, 1);
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW, LabelId.VERIFIED);
+    assertThat(change.permittedLabels.keySet())
+        .containsExactly(LabelId.CODE_REVIEW, LabelId.VERIFIED);
+    assertPermitted(change, LabelId.CODE_REVIEW, 2);
+    assertPermitted(change, LabelId.VERIFIED, 0, 1);
 
     // ignore the new label by Prolog submit rule and assert that the label is
     // no longer returned
@@ -3296,8 +3473,8 @@
     push2.to(RefNames.REFS_CONFIG);
 
     change = gApi.changes().id(r.getChangeId()).get();
-    assertPermitted(change, "Code-Review", 2);
-    assertPermitted(change, "Verified");
+    assertPermitted(change, LabelId.CODE_REVIEW, 2);
+    assertPermitted(change, LabelId.VERIFIED);
 
     // add an approval on the new label and assert that the label is now
     // returned although it is ignored by the Prolog submit rule and hence not
@@ -3308,9 +3485,9 @@
         .review(new ReviewInput().label(verified.getName(), verified.getMax().getValue()));
 
     change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.labels.keySet()).containsExactly("Code-Review", "Verified");
-    assertPermitted(change, "Code-Review", 2);
-    assertPermitted(change, "Verified");
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW, LabelId.VERIFIED);
+    assertPermitted(change, LabelId.CODE_REVIEW, 2);
+    assertPermitted(change, LabelId.VERIFIED);
 
     // remove label and assert that it's no longer returned for existing
     // changes, even if there is an approval for it
@@ -3325,9 +3502,9 @@
         .update();
 
     change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.labels.keySet()).containsExactly("Code-Review");
-    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
-    assertPermitted(change, "Code-Review", 2);
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertPermitted(change, LabelId.CODE_REVIEW, 2);
   }
 
   @Test
@@ -3420,9 +3597,10 @@
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
     assertThat(change.submissionId).isNotNull();
-    assertThat(change.labels.keySet()).containsExactly("Code-Review", "Non-Author-Code-Review");
-    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
-    assertPermitted(change, "Code-Review", 0, 1, 2);
+    assertThat(change.labels.keySet())
+        .containsExactly(LabelId.CODE_REVIEW, "Non-Author-Code-Review");
+    assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertPermitted(change, LabelId.CODE_REVIEW, 0, 1, 2);
   }
 
   @Test
@@ -3436,8 +3614,8 @@
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
     assertThat(change.submissionId).isNotNull();
-    assertThat(change.labels.keySet()).containsExactly("Code-Review");
-    assertPermitted(change, "Code-Review", 0, 1, 2);
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertPermitted(change, LabelId.CODE_REVIEW, 0, 1, 2);
   }
 
   @Test
@@ -3474,7 +3652,7 @@
     gApi.changes().id(triplet).addReviewer(user.username());
 
     ChangeInfo c = gApi.changes().id(triplet).get(DETAILED_LABELS);
-    LabelInfo codeReview = c.labels.get("Code-Review");
+    LabelInfo codeReview = c.labels.get(LabelId.CODE_REVIEW);
     assertThat(codeReview.all).hasSize(1);
     ApprovalInfo approval = codeReview.all.get(0);
     assertThat(approval._accountId).isEqualTo(user.id().get());
@@ -3487,14 +3665,14 @@
         .project(project)
         .forUpdate()
         .add(
-            allowLabel("Code-Review")
+            allowLabel(LabelId.CODE_REVIEW)
                 .ref(heads)
                 .group(REGISTERED_USERS)
                 .range(minPermittedValue, maxPermittedValue))
         .update();
 
     c = gApi.changes().id(triplet).get(DETAILED_LABELS);
-    codeReview = c.labels.get("Code-Review");
+    codeReview = c.labels.get(LabelId.CODE_REVIEW);
     assertThat(codeReview.all).hasSize(1);
     approval = codeReview.all.get(0);
     assertThat(approval._accountId).isEqualTo(user.id().get());
@@ -3508,7 +3686,11 @@
     projectOperations
         .project(project)
         .forUpdate()
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-1, 1))
+        .add(
+            blockLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
         .update();
 
     PushOneCommit.Result r = createChange();
@@ -3517,7 +3699,7 @@
     gApi.changes().id(triplet).addReviewer(user.username());
 
     ChangeInfo c = gApi.changes().id(triplet).get(DETAILED_LABELS);
-    LabelInfo codeReview = c.labels.get("Code-Review");
+    LabelInfo codeReview = c.labels.get(LabelId.CODE_REVIEW);
     assertThat(codeReview.all).hasSize(1);
     ApprovalInfo approval = codeReview.all.get(0);
     assertThat(approval._accountId).isEqualTo(user.id().get());
@@ -3534,7 +3716,7 @@
 
     Map<String, Short> votes =
         gApi.changes().id(changeId).current().reviewer(admin.email()).votes();
-    assertThat(votes.keySet()).containsExactly("Code-Review");
+    assertThat(votes.keySet()).containsExactly(LabelId.CODE_REVIEW);
     assertThat(votes.values()).containsExactly((short) 2);
   }
 
@@ -3543,7 +3725,7 @@
     String changeId = createChange().getChangeId();
 
     // Add a review with invalid label values.
-    ReviewInput input = new ReviewInput().label("Code-Review", 3);
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 3);
     gApi.changes().id(changeId).current().review(input);
 
     assertThrows(
@@ -3567,7 +3749,7 @@
   @GerritConfig(name = "change.strictLabels", value = "true")
   public void strictLabelWithInvalidValue() throws Exception {
     String changeId = createChange().getChangeId();
-    ReviewInput in = new ReviewInput().label("Code-Review", 3);
+    ReviewInput in = new ReviewInput().label(LabelId.CODE_REVIEW, 3);
 
     BadRequestException thrown =
         assertThrows(
@@ -3791,7 +3973,7 @@
   }
 
   private void submittableAfterLosingPermissions(String label) throws Exception {
-    String codeReviewLabel = "Code-Review";
+    String codeReviewLabel = LabelId.CODE_REVIEW;
     AccountGroup.UUID registered = REGISTERED_USERS;
     projectOperations
         .project(project)
@@ -4308,4 +4490,17 @@
   private interface AddReviewerCaller {
     void call(String changeId, String reviewer) throws RestApiException;
   }
+
+  private static class TestWorkInProgressStateChangedListener
+      implements WorkInProgressStateChangedListener {
+    boolean invoked;
+    Boolean wip;
+
+    @Override
+    public void onWorkInProgressStateChanged(Event event) {
+      this.invoked = true;
+      this.wip =
+          event.getChange().workInProgress != null ? event.getChange().workInProgress : false;
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java b/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
index 31198d5..cebce0b 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
@@ -16,9 +16,6 @@
 
 import com.google.gerrit.acceptance.AbstractPluginFieldsTest;
 import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.extensions.annotations.Exports;
-import com.google.gerrit.server.change.ChangeAttributeFactory;
-import com.google.inject.AbstractModule;
 import java.util.Arrays;
 import org.junit.Test;
 
@@ -27,30 +24,6 @@
   // No tests for /detail via the extension API, since the extension API doesn't have that method.
 
   @Test
-  public void queryChangeWithNullAttribute() throws Exception {
-    getChangeWithNullAttribute(
-        id -> pluginInfoFromSingletonList(gApi.changes().query(id.toString()).get()));
-  }
-
-  @Test
-  public void getChangeWithNullAttribute() throws Exception {
-    getChangeWithNullAttribute(
-        id -> pluginInfoFromChangeInfo(gApi.changes().id(id.toString()).get()));
-  }
-
-  @Test
-  public void queryChangeWithSimpleAttribute() throws Exception {
-    getChangeWithSimpleAttribute(
-        id -> pluginInfoFromSingletonList(gApi.changes().query(id.toString()).get()));
-  }
-
-  @Test
-  public void getChangeWithSimpleAttribute() throws Exception {
-    getChangeWithSimpleAttribute(
-        id -> pluginInfoFromChangeInfo(gApi.changes().id(id.toString()).get()));
-  }
-
-  @Test
   public void querySingleChangeWithBulkAttribute() throws Exception {
     getSingleChangeWithPluginDefinedBulkAttribute(
         id -> pluginInfosFromChangeInfos(gApi.changes().query(id.toString()).get()));
@@ -63,22 +36,6 @@
   }
 
   @Test
-  public void queryChangeWithOption() throws Exception {
-    getChangeWithOption(
-        id -> pluginInfoFromSingletonList(gApi.changes().query(id.toString()).get()),
-        (id, opts) ->
-            pluginInfoFromSingletonList(
-                gApi.changes().query(id.toString()).withPluginOptions(opts).get()));
-  }
-
-  @Test
-  public void getChangeWithOption() throws Exception {
-    getChangeWithOption(
-        id -> pluginInfoFromChangeInfo(gApi.changes().id(id.get()).get()),
-        (id, opts) -> pluginInfoFromChangeInfo(gApi.changes().id(id.get()).get(opts)));
-  }
-
-  @Test
   public void queryChangeWithOptionBulkAttribute() throws Exception {
     getChangeWithPluginDefinedBulkAttributeOption(
         id -> pluginInfosFromChangeInfos(gApi.changes().query(id.toString()).get()),
@@ -108,12 +65,6 @@
   }
 
   @Test
-  public void getMultipleChangesWithPluginDefinedAndChangeAttributes() throws Exception {
-    getMultipleChangesWithPluginDefinedBulkAndChangeAttributes(
-        () -> pluginInfosFromChangeInfos(gApi.changes().query("status:open").get()));
-  }
-
-  @Test
   public void getMultipleChangesWithPluginDefinedAttributeInSingleCall() throws Exception {
     getMultipleChangesWithPluginDefinedBulkAttributeInSingleCall(
         () -> pluginInfosFromChangeInfos(gApi.changes().query("status:open").get()));
@@ -124,23 +75,4 @@
     getChangeWithPluginDefinedBulkAttributeWithException(
         id -> pluginInfosFromChangeInfos(Arrays.asList(gApi.changes().id(id.get()).get())));
   }
-
-  static class SimpleAttributeWithExplicitExportModule extends AbstractModule {
-    @Override
-    public void configure() {
-      bind(ChangeAttributeFactory.class)
-          .annotatedWith(Exports.named("simple"))
-          .toInstance((cd, bp, p) -> new MyInfo("change " + cd.getId()));
-    }
-  }
-
-  @Test
-  public void getChangeWithSimpleAttributeWithExplicitExport() throws Exception {
-    // For backwards compatibility with old plugins, allow modules to bind into the
-    // DynamicSet<ChangeAttributeFactory> as if it were a DynamicMap. We only need one variant of
-    // this test to prove that the mapping works.
-    getChangeWithSimpleAttribute(
-        id -> pluginInfoFromChangeInfo(gApi.changes().id(id.toString()).get()),
-        SimpleAttributeWithExplicitExportModule.class);
-  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
index 7d73374..c8b1715 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
@@ -29,9 +29,17 @@
 import com.google.common.collect.Iterables;
 import com.google.common.truth.Correspondence;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -44,11 +52,15 @@
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.events.CommentAddedListener;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.validators.CommentForValidation;
 import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidator;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.restapi.change.OnPostReview;
 import com.google.gerrit.server.restapi.change.PostReview;
 import com.google.gerrit.server.update.CommentsRejectedException;
 import com.google.gerrit.testing.TestCommentHelper;
@@ -58,6 +70,7 @@
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import org.junit.Before;
 import org.junit.Test;
 import org.mockito.ArgumentCaptor;
@@ -69,6 +82,7 @@
   @Inject private CommentValidator mockCommentValidator;
   @Inject private TestCommentHelper testCommentHelper;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
 
   private static final String COMMENT_TEXT = "The comment text";
   private static final CommentForValidation FILE_COMMENT_FOR_VALIDATION =
@@ -447,7 +461,7 @@
     // User adds themselves and changes state
     requestScopeOperations.setApiUser(user.id());
 
-    ReviewInput input = new ReviewInput().label("Code-Review", 1);
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
     gApi.changes().id(r.getChangeId()).current().review(input);
 
     Map<ReviewerState, Collection<AccountInfo>> reviewers =
@@ -474,6 +488,204 @@
     assertThat(reviewer._accountId).isEqualTo(user.id().get());
   }
 
+  @Test
+  public void extendChangeMessageFromPlugin() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    String testMessage = "hello from plugin";
+    TestOnPostReview testOnPostReview = new TestOnPostReview(testMessage);
+    try (Registration registration = extensionRegistry.newRegistration().add(testOnPostReview)) {
+      ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
+      gApi.changes().id(r.getChangeId()).current().review(input);
+      Collection<ChangeMessageInfo> messages = gApi.changes().id(r.getChangeId()).get().messages;
+      assertThat(Iterables.getLast(messages).message)
+          .isEqualTo(String.format("Patch Set 1: Code-Review+1\n\n%s\n", testMessage));
+    }
+  }
+
+  @Test
+  public void extendChangeMessageFromMultiplePlugins() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    String testMessage1 = "hello from plugin 1";
+    String testMessage2 = "message from plugin 2";
+    TestOnPostReview testOnPostReview1 = new TestOnPostReview(testMessage1);
+    TestOnPostReview testOnPostReview2 = new TestOnPostReview(testMessage2);
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testOnPostReview1).add(testOnPostReview2)) {
+      ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
+      gApi.changes().id(r.getChangeId()).current().review(input);
+      Collection<ChangeMessageInfo> messages = gApi.changes().id(r.getChangeId()).get().messages;
+      assertThat(Iterables.getLast(messages).message)
+          .isEqualTo(
+              String.format(
+                  "Patch Set 1: Code-Review+1\n\n%s\n\n%s\n", testMessage1, testMessage2));
+    }
+  }
+
+  @Test
+  public void onPostReviewExtensionThatDoesntExtendTheChangeMessage() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    TestOnPostReview testOnPostReview = new TestOnPostReview(/* message= */ null);
+    try (Registration registration = extensionRegistry.newRegistration().add(testOnPostReview)) {
+      ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
+      gApi.changes().id(r.getChangeId()).current().review(input);
+      Collection<ChangeMessageInfo> messages = gApi.changes().id(r.getChangeId()).get().messages;
+      assertThat(Iterables.getLast(messages).message).isEqualTo("Patch Set 1: Code-Review+1");
+    }
+  }
+
+  @Test
+  public void onPostReviewCallbackGetsCorrectChangeAndPatchSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    amendChange(r.getChangeId());
+
+    TestOnPostReview testOnPostReview = new TestOnPostReview(/* message= */ null);
+    try (Registration registration = extensionRegistry.newRegistration().add(testOnPostReview)) {
+      ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
+
+      // Vote on current patch set.
+      gApi.changes().id(r.getChangeId()).current().review(input);
+      testOnPostReview.assertChangeAndPatchSet(r.getChange().getId(), 2);
+
+      // Vote on old patch set.
+      gApi.changes().id(r.getChangeId()).revision(1).review(input);
+      testOnPostReview.assertChangeAndPatchSet(r.getChange().getId(), 1);
+    }
+  }
+
+  @Test
+  public void onPostReviewCallbackGetsCorrectUser() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    TestOnPostReview testOnPostReview = new TestOnPostReview(/* message= */ null);
+    try (Registration registration = extensionRegistry.newRegistration().add(testOnPostReview)) {
+      ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
+
+      // Vote from admin.
+      gApi.changes().id(r.getChangeId()).current().review(input);
+      testOnPostReview.assertUser(admin);
+
+      // Vote from user.
+      requestScopeOperations.setApiUser(user.id());
+      gApi.changes().id(r.getChangeId()).current().review(input);
+      testOnPostReview.assertUser(user);
+    }
+  }
+
+  @Test
+  public void onPostReviewCallbackGetsCorrectApprovals() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    TestOnPostReview testOnPostReview = new TestOnPostReview(/* message= */ null);
+    try (Registration registration = extensionRegistry.newRegistration().add(testOnPostReview)) {
+      // Add a new vote.
+      ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
+      gApi.changes().id(r.getChangeId()).current().review(input);
+      testOnPostReview.assertApproval(
+          LabelId.CODE_REVIEW, /* expectedOldValue= */ 0, /* expectedNewValue= */ 1);
+
+      // Update an existing vote.
+      input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+      gApi.changes().id(r.getChangeId()).current().review(input);
+      testOnPostReview.assertApproval(
+          LabelId.CODE_REVIEW, /* expectedOldValue= */ 1, /* expectedNewValue= */ 2);
+
+      // Post without changing the vote.
+      input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+      gApi.changes().id(r.getChangeId()).current().review(input);
+      testOnPostReview.assertApproval(
+          LabelId.CODE_REVIEW, /* expectedOldValue= */ null, /* expectedNewValue= */ 2);
+
+      // Delete the vote.
+      input = new ReviewInput().label(LabelId.CODE_REVIEW, 0);
+      gApi.changes().id(r.getChangeId()).current().review(input);
+      testOnPostReview.assertApproval(
+          LabelId.CODE_REVIEW, /* expectedOldValue= */ 2, /* expectedNewValue= */ 0);
+    }
+  }
+
+  @Test
+  public void votingTheSameVoteSecondTime() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
+    sender.clear();
+
+    // Add a new vote.
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+    assertThat(r.getChange().approvals().values()).hasSize(1);
+
+    // Post without changing the vote.
+    input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    // Second vote replaced the original vote, so still only one vote.
+    assertThat(r.getChange().approvals().values()).hasSize(1);
+    List<ChangeMessageInfo> changeMessages = gApi.changes().id(r.getChangeId()).messages();
+
+    // Only the last change message is about Code-Review+2
+    assertThat(Iterables.getLast(changeMessages).message).isEqualTo("Patch Set 1: Code-Review+2");
+    changeMessages.remove(changeMessages.size() - 1);
+    assertThat(Iterables.getLast(changeMessages).message)
+        .isNotEqualTo("Patch Set 1: Code-Review+2");
+
+    // Only one email is about Code-Review +2 was sent.
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .contains("Patch Set 1: Code-Review+2");
+  }
+
+  @Test
+  public void votingTheSameVoteSecondTimeExtendsOnPostReviewWithOldNullValue() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add a new vote.
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+    assertThat(r.getChange().approvals().values()).hasSize(1);
+
+    TestOnPostReview testOnPostReview = new TestOnPostReview(/* message= */ null);
+    try (Registration registration = extensionRegistry.newRegistration().add(testOnPostReview)) {
+      // Post without changing the vote.
+      input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+      gApi.changes().id(r.getChangeId()).current().review(input);
+
+      testOnPostReview.assertApproval(
+          LabelId.CODE_REVIEW, /* expectedOldValue= */ null, /* expectedNewValue= */ 2);
+    }
+  }
+
+  @Test
+  public void votingTheSameVoteSecondTimeDoesNotFireOnCommentAdded() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add a new vote.
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+    assertThat(r.getChange().approvals().values()).hasSize(1);
+
+    TestListener testListener = new TestListener();
+    try (Registration registration = extensionRegistry.newRegistration().add(testListener)) {
+      // Post without changing the vote.
+      input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+      gApi.changes().id(r.getChangeId()).current().review(input);
+
+      // Event not fired.
+      assertThat(testListener.lastCommentAddedEvent).isNull();
+    }
+  }
+
+  private static class TestListener implements CommentAddedListener {
+    public CommentAddedListener.Event lastCommentAddedEvent;
+
+    @Override
+    public void onCommentAdded(Event event) {
+      lastCommentAddedEvent = event;
+    }
+  }
+
   private List<RobotCommentInfo> getRobotComments(String changeId) throws RestApiException {
     return gApi.changes().id(changeId).robotComments().values().stream()
         .flatMap(Collection::stream)
@@ -495,4 +707,50 @@
         .comparingElementsUsing(COMMENT_CORRESPONDENCE)
         .containsExactly(commentsForValidation);
   }
+
+  private static class TestOnPostReview implements OnPostReview {
+    private final Optional<String> message;
+
+    private Change.Id changeId;
+    private PatchSet.Id patchSetId;
+    private Account.Id accountId;
+    private Map<String, Short> oldApprovals;
+    private Map<String, Short> approvals;
+
+    TestOnPostReview(@Nullable String message) {
+      this.message = Optional.ofNullable(message);
+    }
+
+    @Override
+    public Optional<String> getChangeMessageAddOn(
+        IdentifiedUser user,
+        ChangeNotes changeNotes,
+        PatchSet patchSet,
+        Map<String, Short> oldApprovals,
+        Map<String, Short> approvals) {
+      this.changeId = changeNotes.getChangeId();
+      this.patchSetId = patchSet.id();
+      this.accountId = user.getAccountId();
+      this.oldApprovals = oldApprovals;
+      this.approvals = approvals;
+      return message;
+    }
+
+    public void assertChangeAndPatchSet(Change.Id expectedChangeId, int expectedPatchSetNum) {
+      assertThat(changeId).isEqualTo(expectedChangeId);
+      assertThat(patchSetId.get()).isEqualTo(expectedPatchSetNum);
+    }
+
+    public void assertUser(TestAccount expectedUser) {
+      assertThat(accountId).isEqualTo(expectedUser.id());
+    }
+
+    public void assertApproval(
+        String labelName, @Nullable Integer expectedOldValue, int expectedNewValue) {
+      assertThat(oldApprovals)
+          .containsExactly(
+              labelName, expectedOldValue != null ? expectedOldValue.shortValue() : null);
+      assertThat(approvals).containsExactly(labelName, (short) expectedNewValue);
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
index a3a089f..6cf3f3e 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
@@ -290,8 +290,28 @@
     gApi.changes().id(result.getChangeId()).revision(result.getCommit().name()).submit();
     RevertInput revertInput = new RevertInput();
     revertInput.message = "Message from input";
-    assertThat(gApi.changes().id(result.getChangeId()).revert(revertInput).get().subject)
-        .isEqualTo(revertInput.message);
+    ChangeInfo revertChange = gApi.changes().id(result.getChangeId()).revert(revertInput).get();
+    assertThat(revertChange.subject).isEqualTo(revertInput.message);
+    assertThat(gApi.changes().id(revertChange.id).current().commit(false).message)
+        .isEqualTo(String.format("Message from input\n\nChange-Id: %s\n", revertChange.changeId));
+  }
+
+  @Test
+  public void revertWithSetMessageChangeIdIgnored() throws Exception {
+    PushOneCommit.Result result = createChange();
+    gApi.changes().id(result.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(result.getChangeId()).revision(result.getCommit().name()).submit();
+    RevertInput revertInput = new RevertInput();
+    String fakeChangeId = "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    String commitSubject = "Message from input";
+    revertInput.message = String.format("%s\n\nChange-Id: %s\n", commitSubject, fakeChangeId);
+    ChangeInfo revertChange = gApi.changes().id(result.getChangeId()).revert(revertInput).get();
+    // ChangeId provided in revert input is ignored.
+    assertThat(revertChange.changeId).isNotEqualTo(fakeChangeId);
+    assertThat(revertChange.subject).isEqualTo(commitSubject);
+    // ChangeId footer was replaced in revert commit message.
+    assertThat(gApi.changes().id(revertChange.id).current().commit(false).message)
+        .isEqualTo(String.format("Message from input\n\nChange-Id: %s\n", revertChange.changeId));
   }
 
   @Test
@@ -825,6 +845,38 @@
   }
 
   @Test
+  public void revertSubmissionWithSetMessageChangeIdIgnored() throws Exception {
+    String firstResult = createChange("first change", "a.txt", "message").getChangeId();
+    String secondResult = createChange("second change", "b.txt", "message").getChangeId();
+    approve(firstResult);
+    approve(secondResult);
+    gApi.changes().id(secondResult).current().submit();
+    RevertInput revertInput = new RevertInput();
+    String fakeChangeId = "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    String commitSubject = "Message from input";
+    String revertMessage = String.format("%s\n\nChange-Id: %s\n", commitSubject, fakeChangeId);
+    revertInput.message = revertMessage;
+    List<ChangeInfo> revertChanges =
+        gApi.changes().id(firstResult).revertSubmission(revertInput).revertChanges;
+    assertThat(revertChanges.get(0).subject).isEqualTo("Revert \"first change\"");
+    // ChangeId provided in revert input is ignored.
+    assertThat(revertChanges.get(0).changeId).isNotEqualTo(fakeChangeId);
+    assertThat(revertChanges.get(1).changeId).isNotEqualTo(fakeChangeId);
+    // ChangeId footer was replaced in revert commit message.
+    assertThat(gApi.changes().id(revertChanges.get(0).id).current().commit(false).message)
+        .isEqualTo(
+            String.format(
+                "Revert \"first change\"\n\n%s\n\nChange-Id: %s\n",
+                commitSubject, revertChanges.get(0).changeId));
+    assertThat(revertChanges.get(1).subject).isEqualTo("Revert \"second change\"");
+    assertThat(gApi.changes().id(revertChanges.get(1).id).current().commit(false).message)
+        .isEqualTo(
+            String.format(
+                "Revert \"second change\"\n\n%s\n\nChange-Id: %s\n",
+                commitSubject, revertChanges.get(1).changeId));
+  }
+
+  @Test
   public void revertSubmissionWithoutMessage() throws Exception {
     String firstResult = createChange("first change", "a.txt", "message").getChangeId();
     String secondResult = createChange("second change", "b.txt", "message").getChangeId();
diff --git a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index 58ea6ea..5c59129 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -37,9 +37,13 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -68,6 +72,7 @@
 public class StickyApprovalsIT extends AbstractDaemonTest {
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ChangeOperations changeOperations;
 
   @Inject
   @Named("change_kind")
@@ -80,7 +85,7 @@
       // This way changes to the "Code Review" label don't affect other tests.
       LabelType.Builder codeReview =
           labelBuilder(
-              "Code-Review",
+              LabelId.CODE_REVIEW,
               value(2, "Looks good to me, approved"),
               value(1, "Looks good to me, but someone else must approve"),
               value(0, "No score"),
@@ -90,7 +95,8 @@
       u.getConfig().upsertLabelType(codeReview.build());
 
       LabelType.Builder verified =
-          labelBuilder("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
       verified.setCopyAllScoresIfNoChange(false);
       u.getConfig().upsertLabelType(verified.build());
 
@@ -121,7 +127,7 @@
   @Test
   public void stickyOnAnyScore() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyAnyScore(true));
+      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAnyScore(true));
       u.save();
     }
 
@@ -143,7 +149,7 @@
   @Test
   public void stickyOnMinScore() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyMinScore(true));
+      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMinScore(true));
       u.save();
     }
 
@@ -165,7 +171,7 @@
   @Test
   public void stickyOnMaxScore() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyMaxScore(true));
+      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMaxScore(true));
       u.save();
     }
 
@@ -191,7 +197,7 @@
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig()
           .updateLabelType(
-              "Code-Review", b -> b.setCopyValues(ImmutableList.of((short) -1, (short) 1)));
+              LabelId.CODE_REVIEW, b -> b.setCopyValues(ImmutableList.of((short) -1, (short) 1)));
       u.save();
     }
 
@@ -215,7 +221,8 @@
   @Test
   public void stickyOnTrivialRebase() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyAllScoresOnTrivialRebase(true));
+      u.getConfig()
+          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAllScoresOnTrivialRebase(true));
       u.save();
     }
 
@@ -261,7 +268,7 @@
   @Test
   public void stickyOnNoCodeChange() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("Verified", b -> b.setCopyAllScoresIfNoCodeChange(true));
+      u.getConfig().updateLabelType(LabelId.VERIFIED, b -> b.setCopyAllScoresIfNoCodeChange(true));
       u.save();
     }
 
@@ -286,7 +293,8 @@
   public void stickyOnMergeFirstParentUpdate() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig()
-          .updateLabelType("Code-Review", b -> b.setCopyAllScoresOnMergeFirstParentUpdate(true));
+          .updateLabelType(
+              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresOnMergeFirstParentUpdate(true));
       u.save();
     }
 
@@ -310,7 +318,7 @@
   @Test
   public void notStickyWithCopyOnNoChangeWhenSecondParentIsUpdated() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyAllScoresIfNoChange(true));
+      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfNoChange(true));
       u.save();
     }
 
@@ -325,10 +333,105 @@
   }
 
   @Test
+  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded()
+      throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(
+              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+      u.save();
+    }
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    vote(admin, changeId.toString(), 2, 1);
+    vote(user, changeId.toString(), -2, -1);
+
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .file("new file")
+        .content("new content")
+        .create();
+    ChangeInfo c = detailedChange(changeId.toString());
+
+    // no votes are copied since the list of files changed.
+    assertVotes(c, admin, 0, 0);
+    assertVotes(c, user, 0, 0);
+  }
+
+  @Test
+  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted()
+      throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(
+              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+      u.save();
+    }
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    vote(admin, changeId.toString(), 2, 1);
+    vote(user, changeId.toString(), -2, -1);
+
+    changeOperations.change(changeId).newPatchset().file("file").delete().create();
+    ChangeInfo c = detailedChange(changeId.toString());
+
+    // no votes are copied since the list of files changed.
+    assertVotes(c, admin, 0, 0);
+    assertVotes(c, user, 0, 0);
+  }
+
+  @Test
+  public void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified()
+      throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(
+              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+      u.save();
+    }
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    vote(admin, changeId.toString(), 2, 1);
+    vote(user, changeId.toString(), -2, -1);
+
+    changeOperations.change(changeId).newPatchset().file("file").content("new content").create();
+    ChangeInfo c = detailedChange(changeId.toString());
+
+    // only code review votes are copied since copyAllScoresIfListOfFilesDidNotChange is
+    // configured for that label, and list of files didn't change.
+    assertVotes(c, admin, 2, 0);
+    assertVotes(c, user, -2, 0);
+  }
+
+  @Test
+  public void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(
+              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+      u.save();
+    }
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    vote(admin, changeId.toString(), 2, 1);
+    vote(user, changeId.toString(), -2, -1);
+
+    changeOperations.change(changeId).newPatchset().file("file").renameTo("new_file").create();
+    ChangeInfo c = detailedChange(changeId.toString());
+
+    // only code review votes are copied since copyAllScoresIfListOfFilesDidNotChange is
+    // configured for that label, and list of files didn't change (rename is still the same file).
+    assertVotes(c, admin, 2, 0);
+    assertVotes(c, user, -2, 0);
+  }
+
+  @Test
   public void removedVotesNotSticky() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyAllScoresOnTrivialRebase(true));
-      u.getConfig().updateLabelType("Verified", b -> b.setCopyAllScoresIfNoCodeChange(true));
+      u.getConfig()
+          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAllScoresOnTrivialRebase(true));
+      u.getConfig().updateLabelType(LabelId.VERIFIED, b -> b.setCopyAllScoresIfNoCodeChange(true));
       u.save();
     }
 
@@ -357,8 +460,8 @@
   @Test
   public void stickyAcrossMultiplePatchSets() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyMaxScore(true));
-      u.getConfig().updateLabelType("Verified", b -> b.setCopyAllScoresIfNoCodeChange(true));
+      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMaxScore(true));
+      u.getConfig().updateLabelType(LabelId.VERIFIED, b -> b.setCopyAllScoresIfNoCodeChange(true));
       u.save();
     }
 
@@ -383,8 +486,8 @@
     // change kind against all prior patch sets. This is a regression that made Gerrit do expensive
     // work in O(num-patch-sets). This test ensures that we aren't regressing.
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyMaxScore(true));
-      u.getConfig().updateLabelType("Verified", b -> b.setCopyAllScoresIfNoCodeChange(true));
+      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMaxScore(true));
+      u.getConfig().updateLabelType(LabelId.VERIFIED, b -> b.setCopyAllScoresIfNoCodeChange(true));
       u.save();
     }
 
@@ -415,8 +518,8 @@
   @Test
   public void copyMinMaxAcrossMultiplePatchSets() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyMaxScore(true));
-      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyMinScore(true));
+      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMaxScore(true));
+      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMinScore(true));
       u.save();
     }
 
@@ -454,7 +557,7 @@
 
   @Test
   public void deleteStickyVote() throws Exception {
-    String label = "Code-Review";
+    String label = LabelId.CODE_REVIEW;
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().updateLabelType(label, b -> b.setCopyMaxScore(true));
       u.save();
@@ -468,10 +571,37 @@
     assertVotes(detailedChange(changeId), admin, label, 2, REWORK);
 
     // Delete vote that was copied via sticky approval
-    deleteVote(admin, changeId, "Code-Review");
+    deleteVote(admin, changeId, label);
     assertVotes(detailedChange(changeId), admin, label, 0, REWORK);
   }
 
+  @Test
+  public void canVoteMultipleTimesOnNewPatchsets() throws Exception {
+    // Code-Review will be sticky.
+    String label = LabelId.CODE_REVIEW;
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().updateLabelType(label, b -> b.setCopyAnyScore(true));
+      u.save();
+    }
+
+    PushOneCommit.Result r = createChange();
+
+    // Add a new vote.
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    // Make a new patchset, keeping the Code-Review +2 vote.
+    amendChange(r.getChangeId());
+
+    // Post without changing the vote.
+    input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    // There is a vote both on patchset 1 and on patchset 2, although both votes are Code-Review +2.
+    assertThat(r.getChange().approvals().get(PatchSet.id(r.getChange().getId(), 1))).hasSize(1);
+    assertThat(r.getChange().approvals().get(PatchSet.id(r.getChange().getId(), 2))).hasSize(1);
+  }
+
   private void assertChangeKindCacheContains(ObjectId prior, ObjectId next) {
     ChangeKind kind =
         changeKindCache.getIfPresent(ChangeKindCacheImpl.Key.create(prior, next, "recursive"));
@@ -592,7 +722,7 @@
     PushOneCommit.Result r = push.to("refs/for/master");
     r.assertOkStatus();
     RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    ReviewInput in = new ReviewInput().label("Code-Review", 2).label("Verified", 1);
+    ReviewInput in = new ReviewInput().label(LabelId.CODE_REVIEW, 2).label(LabelId.VERIFIED, 1);
     revision.review(in);
     revision.submit();
 
@@ -704,7 +834,9 @@
       throws Exception {
     requestScopeOperations.setApiUser(user.id());
     ReviewInput in =
-        new ReviewInput().label("Code-Review", codeReviewVote).label("Verified", verifiedVote);
+        new ReviewInput()
+            .label(LabelId.CODE_REVIEW, codeReviewVote)
+            .label(LabelId.VERIFIED, verifiedVote);
     gApi.changes().id(changeId).current().review(in);
   }
 
@@ -719,8 +851,8 @@
 
   private void assertVotes(
       ChangeInfo c, TestAccount user, int codeReviewVote, int verifiedVote, ChangeKind changeKind) {
-    assertVotes(c, user, "Code-Review", codeReviewVote, changeKind);
-    assertVotes(c, user, "Verified", verifiedVote, changeKind);
+    assertVotes(c, user, LabelId.CODE_REVIEW, codeReviewVote, changeKind);
+    assertVotes(c, user, LabelId.VERIFIED, verifiedVote, changeKind);
   }
 
   private void assertVotes(
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
new file mode 100644
index 0000000..5ca7310
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
@@ -0,0 +1,399 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.server.patch.filediff.Edit;
+import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.inject.Inject;
+import java.util.Iterator;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+
+public class SubmitWithStickyApprovalDiffIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private ChangeOperations changeOperations;
+
+  @Before
+  public void setup() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      // Overwrite "Code-Review" label that is inherited from All-Projects.
+      // This way changes to the "Code Review" label don't affect other tests.
+      // Also make the vote sticky.
+      LabelType.Builder codeReview =
+          labelBuilder(
+              LabelId.CODE_REVIEW,
+              value(2, "Looks good to me, approved"),
+              value(1, "Looks good to me, but someone else must approve"),
+              value(0, "No score"),
+              value(-1, "I would prefer that you didn't submit this"),
+              value(-2, "Do not submit"));
+      codeReview.setCopyAnyScore(true);
+      u.getConfig().upsertLabelType(codeReview.build());
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
+  }
+
+  @Test
+  public void diffChangeMessageOnSubmitWithStickyVote_modifiedFileWithReplaces() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file("file")
+            .content("content\naa\nsF\naa\naaa\nsomething\nfoo\nbla\ndeletedEnd")
+            .create();
+
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .file("file")
+        .content("content\naa\nsS\naa\naaa\ndifferent\nfoo\nbla")
+        .create();
+
+    // add a reviewer to ensure an email is sent.
+    gApi.changes().id(changeId.get()).addReviewer(user.email());
+
+    gApi.changes().id(changeId.get()).current().submit();
+
+    assertDiffChangeMessageAndEmailWithStickyApproval(
+        Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message,
+        /* file= */ "file",
+        /* insertions= */ 3,
+        /* deletions= */ 4,
+        /* edits= */ ImmutableList.of(
+            Edit.create(2, 3, 2, 3), Edit.create(5, 6, 5, 6), Edit.create(7, 9, 7, 8)),
+        /* previousLines= */ ImmutableList.of(
+            "-  sF\n", "-  something\n", "-  bla\n-  " + "deletedEnd\n"),
+        /* newLines= */ ImmutableList.of("+  sS\n", "+  different\n", "+  bla\n"),
+        /* oldFileName= */ null);
+  }
+
+  @Test
+  public void diffChangeMessageOnSubmitWithStickyVote_modifiedFileWithInsertionAndDeletion()
+      throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file("file")
+            .content("content\naa\nbb\ncc" + "\ndd\nee\nff\nTODELETE1\nTODELETE2\ngg\nend")
+            .create();
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .file("file")
+        .content("content\naa\nbb\ncc\nINSERTION\nINSERTED\nVERY\nLONG\ndd\nee\nff\ngg\nend")
+        .create();
+
+    // add a reviewer to ensure an email is sent.
+    gApi.changes().id(changeId.get()).addReviewer(user.email());
+
+    gApi.changes().id(changeId.get()).current().submit();
+
+    assertDiffChangeMessageAndEmailWithStickyApproval(
+        Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message,
+        /* file= */ "file",
+        /* insertions= */ 4,
+        /* deletions= */ 2,
+        /* edits= */ ImmutableList.of(Edit.create(4, 4, 4, 8), Edit.create(7, 9, 7, 7)),
+        /* previousLines= */ ImmutableList.of("-  TODELETE1\n-  TODELETE2\n"),
+        /* newLines= */ ImmutableList.of("+  INSERTION\n+  INSERTED\n+  VERY\n+  LONG\n"),
+        /* oldFileName= */ null);
+  }
+
+  @Test
+  @GerritConfig(name = "change.cumulativeCommentSizeLimit", value = "1k")
+  public void autoGeneratedPostSubmitDiffIsNotPartOfTheCommentSizeLimit() throws Exception {
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+    String content = new String(new char[800]).replace("\0", "a");
+    changeOperations.change(changeId).newPatchset().file("file").content(content).create();
+
+    // Post a submit diff that is almost the cumulativeCommentSizeLimit
+    gApi.changes().id(changeId.get()).current().submit();
+    assertThat(Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message)
+        .doesNotContain("many unreviewed changes");
+
+    // unrelated comment and change message posting works fine, since the post submit diff is not
+    // counted towards the cumulativeCommentSizeLimit for unrelated follow-up comments.
+    // 800 + 400 + 400 > 1k, but 400 + 400 < 1k, hence these comments are accepted (the original
+    // 800 is not counted).
+    String message = new String(new char[400]).replace("\0", "a");
+    ReviewInput reviewInput = new ReviewInput().message(message);
+    CommentInput commentInput = new CommentInput();
+    commentInput.line = 1;
+    commentInput.message = message;
+    commentInput.path = "file";
+    reviewInput.comments = ImmutableMap.of("file", ImmutableList.of(commentInput));
+
+    gApi.changes().id(changeId.get()).current().review(reviewInput);
+  }
+
+  @Test
+  @GerritConfig(name = "change.cumulativeCommentSizeLimit", value = "1k")
+  public void postSubmitDiffCannotBeTooBig() throws Exception {
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    String content = new String(new char[1100]).replace("\0", "a");
+
+    changeOperations.change(changeId).newPatchset().file("file").content(content).create();
+
+    // Post submit diff is over the cumulativeCommentSizeLimit, so we shorten the message.
+    gApi.changes().id(changeId.get()).current().submit();
+    assertThat(Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message)
+        .isEqualTo(
+            "Change has been successfully merged\n\n1 is the latest approved patch-set.\nThe "
+                + "change was submitted "
+                + "with many unreviewed changes (the diff is too large to show). Please review the "
+                + "diff.");
+  }
+
+  @Test
+  public void diffChangeMessageOnSubmitWithStickyVote_addedFile() throws Exception {
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .file("file")
+        .content("content\nmore content\nlast content")
+        .create();
+
+    // add a reviewer to ensure an email is sent.
+    gApi.changes().id(changeId.get()).addReviewer(user.email());
+
+    gApi.changes().id(changeId.get()).current().submit();
+
+    assertDiffChangeMessageAndEmailWithStickyApproval(
+        Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message,
+        /* file= */ "file",
+        /* insertions= */ 3,
+        /* deletions= */ 0,
+        /* edits= */ ImmutableList.of(Edit.create(0, 0, 0, 3)),
+        /* previousLines= */ ImmutableList.of(),
+        /* newLines= */ ImmutableList.of("+  content\n+  more content\n+  last content\n"),
+        /* oldFileName= */ null);
+  }
+
+  @Test
+  public void diffChangeMessageOnSubmitWithStickyVote_removedFile() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file("file")
+            .content("content\nmore content\nlast content")
+            .create();
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    changeOperations.change(changeId).newPatchset().file("file").delete().create();
+
+    // add a reviewer to ensure an email is sent.
+    gApi.changes().id(changeId.get()).addReviewer(user.email());
+
+    gApi.changes().id(changeId.get()).current().submit();
+
+    assertDiffChangeMessageAndEmailWithStickyApproval(
+        Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message,
+        /* file= */ "file",
+        /* insertions= */ 0,
+        /* deletions= */ 3,
+        /* edits= */ ImmutableList.of(Edit.create(0, 3, 0, 0)),
+        /* previousLines= */ ImmutableList.of("-  content\n-  more content\n-  last content\n"),
+        /* newLines= */ ImmutableList.of(),
+        /* oldFileName= */ null);
+  }
+
+  @Test
+  public void diffChangeMessageOnSubmitWithStickyVote_renamedFile() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file("file")
+            .content("content\nmoreContent")
+            .create();
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    changeOperations.change(changeId).newPatchset().file("file").renameTo("new_file").create();
+
+    // add a reviewer to ensure an email is sent.
+    gApi.changes().id(changeId.get()).addReviewer(user.email());
+
+    gApi.changes().id(changeId.get()).current().submit();
+
+    assertDiffChangeMessageAndEmailWithStickyApproval(
+        Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message,
+        /* file= */ "new_file",
+        /* insertions= */ 0,
+        /* deletions= */ 0,
+        /* edits= */ ImmutableList.of(),
+        /* previousLines= */ ImmutableList.of(),
+        /* newLines= */ ImmutableList.of(),
+        /* oldFileName= */ "file");
+  }
+
+  @Test
+  public void noDiffChangeMessageOnSubmitWhenVotedOnLastPatchset() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file("file")
+            .content("content\nmoreContent")
+            .create();
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    changeOperations.change(changeId).newPatchset().file("file").renameTo("new_file").create();
+
+    // Approve last patch-set again, although there is already a +2 on the change (since it's
+    // sticky).
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeId.get()).current().submit();
+
+    assertThat(Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message.trim())
+        .isEqualTo("Change has been successfully merged");
+  }
+
+  @Test
+  public void diffChangeMessageOnSubmitWithStickyVote_approvedPatchset() throws Exception {
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+    changeOperations.change(changeId).newPatchset().create();
+
+    // approve patch-set 2
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    // create patch-set 3
+    changeOperations.change(changeId).newPatchset().create();
+
+    gApi.changes().id(changeId.get()).current().submit();
+
+    // patch-set 2 was the latest approved one.
+    assertThat(Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message)
+        .contains("2 is the latest approved patch-set.");
+  }
+
+  @Test
+  public void diffChangeMessageOnSubmitWithStickyVote_noChanges() throws Exception {
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    // no file changed
+    changeOperations.change(changeId).newPatchset().create();
+
+    gApi.changes().id(changeId.get()).current().submit();
+
+    // No other content in the message since the diff is the same.
+    assertThat(Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message)
+        .isEqualTo(
+            "Change has been successfully merged\n\n1 is the latest approved patch-set.\n"
+                + "No files were changed between the latest approved patch-set and the submitted"
+                + " one.\n");
+  }
+
+  private void assertDiffChangeMessageAndEmailWithStickyApproval(
+      String message,
+      String file,
+      int insertions,
+      int deletions,
+      List<Edit> edits,
+      List<String> previousLines,
+      List<String> newLines,
+      String oldFileName) {
+    String expectedMessage =
+        "1 is the latest approved patch-set.\n"
+            + "The change was submitted with unreviewed changes in the following files:\n"
+            + "\n"
+            + String.format("The name of the file: %s\n", file)
+            + String.format("Insertions: %d, Deletions: %d.\n\n", insertions, deletions);
+
+    if (oldFileName != null) {
+      expectedMessage += String.format("The file %s was renamed to %s\n", oldFileName, file);
+    }
+
+    Iterator<String> previousLinesIterator = previousLines.iterator();
+    Iterator<String> newLinesIterator = newLines.iterator();
+    if (!edits.isEmpty()) {
+      expectedMessage += "```\n";
+    }
+    for (Edit edit : edits) {
+      if (edit.beginA() == edit.endA()) {
+        // Insertion
+        expectedMessage += String.format("@@ +%d:%d @@\n", edit.beginB(), edit.endB());
+        expectedMessage += newLinesIterator.next();
+        expectedMessage += "\n";
+        continue;
+      }
+      if (edit.beginB() == edit.endB()) {
+        // Deletion
+        expectedMessage += String.format("@@ -%d:%d @@\n", edit.beginA(), edit.endA());
+        expectedMessage += previousLinesIterator.next();
+        expectedMessage += "\n";
+        continue;
+      }
+      // Replace
+      expectedMessage +=
+          String.format(
+              "@@ -%d:%d, +%d:%d @@\n", edit.beginA(), edit.endA(), edit.beginB(), edit.endB());
+      expectedMessage += previousLinesIterator.next();
+      expectedMessage += newLinesIterator.next();
+      expectedMessage += "\n";
+    }
+    if (!edits.isEmpty()) {
+      expectedMessage += "```\n";
+    }
+    String expectedChangeMessage = "Change has been successfully merged\n\n" + expectedMessage;
+    assertThat(message.trim()).isEqualTo(expectedChangeMessage.trim());
+    assertThat(Iterables.getLast(sender.getMessages()).body()).contains(expectedMessage);
+    assertThat(Iterables.getLast(sender.getMessages()).htmlBody()).contains(expectedMessage);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/group/BUILD b/javatests/com/google/gerrit/acceptance/api/group/BUILD
index 1ba1138..5d7dea1 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/group/BUILD
@@ -7,6 +7,7 @@
     labels = ["api"],
     deps = [
         ":util",
+        "//java/com/google/gerrit/auth",
         "//java/com/google/gerrit/server/group/db/testing",
         "//java/com/google/gerrit/server/group/testing",
         "//java/com/google/gerrit/server/util/time",
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index 8dbec28..fcd6b76 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -54,11 +54,13 @@
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.auth.ldap.FakeLdapGroupBackend;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
@@ -85,7 +87,7 @@
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupIncludeCache;
 import com.google.gerrit.server.account.GroupsSnapshotReader;
-import com.google.gerrit.server.auth.ldap.FakeLdapGroupBackend;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.PeriodicGroupIndexer;
 import com.google.gerrit.server.group.SystemGroupBackend;
@@ -916,7 +918,9 @@
   @Test
   public void defaultGroupsCreated() throws Exception {
     Iterable<String> names = gApi.groups().list().getAsMap().keySet();
-    assertThat(names).containsAtLeast("Administrators", "Service Users").inOrder();
+    assertThat(names)
+        .containsAtLeast("Administrators", ServiceUserClassifier.SERVICE_USERS)
+        .inOrder();
   }
 
   @Test
@@ -1547,7 +1551,7 @@
         .project(project)
         .forUpdate()
         .add(
-            allowLabel("Code-Review")
+            allowLabel(LabelId.CODE_REVIEW)
                 .ref(RefNames.REFS_GROUPS + "*")
                 .group(REGISTERED_USERS)
                 .range(-2, 2))
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
index f1d537f..45a895a 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
@@ -19,14 +19,17 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
@@ -36,9 +39,11 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.inject.Inject;
 import java.util.List;
+import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.RefUpdate.Result;
 import org.eclipse.jgit.lib.Repository;
@@ -48,6 +53,7 @@
 public class CheckAccessIT extends AbstractDaemonTest {
   @Inject private ProjectOperations projectOperations;
   @Inject private GroupOperations groupOperations;
+  @Inject private AllProjectsName allProjectsName;
 
   private Project.NameKey normalProject;
   private Project.NameKey secretProject;
@@ -162,28 +168,37 @@
     String project;
     String permission;
     int want;
+    List<String> expectedDebugLogs;
 
-    static TestCase project(String mail, String project, int want) {
+    static TestCase project(String mail, String project, int want, List<String> expectedDebugLogs) {
       TestCase t = new TestCase();
       t.input = new AccessCheckInput();
       t.input.account = mail;
       t.project = project;
       t.want = want;
+      t.expectedDebugLogs = expectedDebugLogs;
       return t;
     }
 
-    static TestCase projectRef(String mail, String project, String ref, int want) {
+    static TestCase projectRef(
+        String mail, String project, String ref, int want, List<String> expectedDebugLogs) {
       TestCase t = new TestCase();
       t.input = new AccessCheckInput();
       t.input.account = mail;
       t.input.ref = ref;
       t.project = project;
       t.want = want;
+      t.expectedDebugLogs = expectedDebugLogs;
       return t;
     }
 
     static TestCase projectRefPerm(
-        String mail, String project, String ref, String permission, int want) {
+        String mail,
+        String project,
+        String ref,
+        String permission,
+        int want,
+        List<String> expectedDebugLogs) {
       TestCase t = new TestCase();
       t.input = new AccessCheckInput();
       t.input.account = mail;
@@ -191,6 +206,7 @@
       t.input.permission = permission;
       t.project = project;
       t.want = want;
+      t.expectedDebugLogs = expectedDebugLogs;
       return t;
     }
   }
@@ -212,32 +228,115 @@
   public void accessible() throws Exception {
     List<TestCase> inputs =
         ImmutableList.of(
+            // Test 1
             TestCase.projectRefPerm(
                 user.email(),
                 normalProject.get(),
                 "refs/heads/master",
                 Permission.VIEW_PRIVATE_CHANGES,
-                403),
-            TestCase.project(user.email(), normalProject.get(), 200),
-            TestCase.project(user.email(), secretProject.get(), 403),
+                403,
+                ImmutableList.of(
+                    "'user' can perform 'read' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/heads/*'",
+                    "'user' cannot perform 'viewPrivateChanges' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/heads/master'")),
+            // Test 2
+            TestCase.project(
+                user.email(),
+                normalProject.get(),
+                200,
+                ImmutableList.of(
+                    "'user' can perform 'read' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/heads/*'")),
+            // Test 3
+            TestCase.project(
+                user.email(),
+                secretProject.get(),
+                403,
+                ImmutableList.of(
+                    "'user' cannot perform 'read' with force=false on project '"
+                        + secretProject.get()
+                        + "' for ref 'refs/heads/*' because this permission is blocked",
+                    "'user' cannot perform 'read' with force=false on project '"
+                        + secretProject.get()
+                        + "' for ref 'refs/meta/version' because this permission is blocked")),
+            // Test 4
             TestCase.projectRef(
-                user.email(), secretRefProject.get(), "refs/heads/secret/master", 403),
+                user.email(),
+                secretRefProject.get(),
+                "refs/heads/secret/master",
+                403,
+                ImmutableList.of(
+                    "'user' can perform 'read' with force=false on project '"
+                        + secretRefProject.get()
+                        + "' for ref 'refs/heads/*'",
+                    "'user' cannot perform 'read' with force=false on project '"
+                        + secretRefProject.get()
+                        + "' for ref 'refs/heads/secret/master' because this permission is blocked")),
+            // Test 5
             TestCase.projectRef(
-                privilegedUser.email(), secretRefProject.get(), "refs/heads/secret/master", 200),
-            TestCase.projectRef(privilegedUser.email(), normalProject.get(), null, 200),
-            TestCase.projectRef(privilegedUser.email(), secretProject.get(), null, 200),
+                privilegedUser.email(),
+                secretRefProject.get(),
+                "refs/heads/secret/master",
+                200,
+                ImmutableList.of(
+                    "'privilegedUser' can perform 'read' with force=false on project '"
+                        + secretRefProject.get()
+                        + "' for ref 'refs/heads/*'",
+                    "'privilegedUser' can perform 'read' with force=false on project '"
+                        + secretRefProject.get()
+                        + "' for ref 'refs/heads/secret/master'")),
+            // Test 6
+            TestCase.projectRef(
+                privilegedUser.email(),
+                normalProject.get(),
+                null,
+                200,
+                ImmutableList.of(
+                    "'privilegedUser' can perform 'read' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/heads/*'")),
+            // Test 7
+            TestCase.projectRef(
+                privilegedUser.email(),
+                secretProject.get(),
+                null,
+                200,
+                ImmutableList.of(
+                    "'privilegedUser' can perform 'read' with force=false on project '"
+                        + secretProject.get()
+                        + "' for ref 'refs/*'")),
+            // Test 8
             TestCase.projectRefPerm(
                 privilegedUser.email(),
                 normalProject.get(),
                 "refs/heads/master",
                 Permission.VIEW_PRIVATE_CHANGES,
-                200),
+                200,
+                ImmutableList.of(
+                    "'privilegedUser' can perform 'read' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/heads/*'",
+                    "'privilegedUser' can perform 'viewPrivateChanges' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/heads/master'")),
+            // Test 9
             TestCase.projectRefPerm(
                 privilegedUser.email(),
                 normalProject.get(),
                 "refs/heads/master",
                 Permission.FORGE_SERVER,
-                200));
+                200,
+                ImmutableList.of(
+                    "'privilegedUser' can perform 'read' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/heads/*'",
+                    "'privilegedUser' can perform 'forgeServerAsCommitter' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/heads/master'")));
 
     for (TestCase tc : inputs) {
       String in = newGson().toJson(tc.input);
@@ -273,6 +372,14 @@
         default:
           assertWithMessage(String.format("unknown code %d", want)).fail();
       }
+
+      if (!info.debugLogs.equals(tc.expectedDebugLogs)) {
+        assertWithMessage(
+                String.format(
+                    "check.access(%s, %s) = %s, want %s",
+                    tc.project, in, info.debugLogs, tc.expectedDebugLogs))
+            .fail();
+      }
     }
   }
 
@@ -290,4 +397,34 @@
     assertThat(info.status).isEqualTo(200);
     assertThat(info.message).contains("no branches");
   }
+
+  @Test
+  @Sandboxed
+  public void noRules() throws Exception {
+    normalProject = projectOperations.newProject().create();
+
+    for (AccessSection section :
+        projectOperations.project(allProjectsName).getProjectConfig().getAccessSections()) {
+      if (!section.getName().startsWith(Constants.R_REFS)) {
+        continue;
+      }
+      for (Permission permission : section.getPermissions()) {
+        projectOperations
+            .project(allProjectsName)
+            .forUpdate()
+            .remove(permissionKey(permission.getName()).ref(section.getName()).build())
+            .update();
+      }
+    }
+    AccessCheckInput input = new AccessCheckInput();
+    input.account = privilegedUser.email();
+    input.permission = Permission.READ;
+    input.ref = "refs/heads/main";
+
+    AccessCheckInfo info = gApi.projects().name(normalProject.get()).checkAccess(input);
+    assertThat(info.status).isEqualTo(403);
+
+    assertThat(info.debugLogs).isNotEmpty();
+    assertThat(info.debugLogs.get(0)).contains("Found no rules");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
index 80e04c0..bdb03d2 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
@@ -16,11 +16,13 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
@@ -36,6 +38,7 @@
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.inject.Inject;
 import java.util.Iterator;
 import java.util.List;
@@ -96,77 +99,272 @@
   }
 
   @Test
-  public void cherryPickWithoutMessage() throws Exception {
-    String branch = "foo";
+  public void cherryPickWithoutMessageSameBranch() throws Exception {
+    String destBranch = "master";
 
     // Create change to cherry-pick
-    RevCommit revCommit = createChange().getCommit();
-
-    // Create target branch to cherry-pick to.
-    gApi.projects().name(project.get()).branch(branch).create(new BranchInput());
+    PushOneCommit.Result r = createChange();
+    ChangeInfo changeToCherryPick = info(r.getChangeId());
+    RevCommit commitToCherryPick = r.getCommit();
 
     // Cherry-pick without message.
     CherryPickInput input = new CherryPickInput();
-    input.destination = branch;
-    String changeId =
-        gApi.projects().name(project.get()).commit(revCommit.name()).cherryPick(input).get().id;
+    input.destination = destBranch;
+    ChangeInfo cherryPickResult =
+        gApi.projects()
+            .name(project.get())
+            .commit(commitToCherryPick.name())
+            .cherryPick(input)
+            .get();
 
+    // Expect that the Change-Id of the cherry-picked commit was used for the cherry-pick change.
+    // New patch-set to existing change was uploaded.
+    assertThat(cherryPickResult._number).isEqualTo(changeToCherryPick._number);
+    assertThat(cherryPickResult.revisions).hasSize(2);
+    assertThat(cherryPickResult.changeId).isEqualTo(changeToCherryPick.changeId);
+    assertThat(cherryPickResult.messages).hasSize(2);
+
+    // Cherry-pick of is not set, because the source change was not provided.
+    assertThat(cherryPickResult.cherryPickOfChange).isNull();
+    assertThat(cherryPickResult.cherryPickOfPatchSet).isNull();
     // Expect that the message of the cherry-picked commit was used for the cherry-pick change.
-    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
-    RevisionInfo revInfo = changeInfo.revisions.get(changeInfo.currentRevision);
+    RevisionInfo revInfo = cherryPickResult.revisions.get(cherryPickResult.currentRevision);
     assertThat(revInfo).isNotNull();
-    assertThat(revInfo.commit.message).isEqualTo(revCommit.getFullMessage());
+    assertThat(revInfo.commit.message).isEqualTo(commitToCherryPick.getFullMessage());
   }
 
   @Test
-  public void cherryPickCommitWithoutChangeId() throws Exception {
+  public void cherryPickWithoutMessageOtherBranch() throws Exception {
+    String destBranch = "foo";
+    createBranch(BranchNameKey.create(project, destBranch));
+
+    // Create change to cherry-pick
+    PushOneCommit.Result r = createChange();
+    ChangeInfo changeToCherryPick = info(r.getChangeId());
+    RevCommit commitToCherryPick = r.getCommit();
+
+    // Cherry-pick without message.
     CherryPickInput input = new CherryPickInput();
-    input.destination = "foo";
+    input.destination = destBranch;
+    ChangeInfo cherryPickResult =
+        gApi.projects()
+            .name(project.get())
+            .commit(commitToCherryPick.name())
+            .cherryPick(input)
+            .get();
+
+    // Expect that the Change-Id of the cherry-picked commit was used for the cherry-pick change.
+    // New change in destination branch was created.
+    assertThat(cherryPickResult._number).isGreaterThan(changeToCherryPick._number);
+    assertThat(cherryPickResult.revisions).hasSize(1);
+    assertThat(cherryPickResult.changeId).isEqualTo(changeToCherryPick.changeId);
+    assertThat(cherryPickResult.messages).hasSize(1);
+
+    // Cherry-pick of is not set, because the source change was not provided.
+    assertThat(cherryPickResult.cherryPickOfChange).isNull();
+    assertThat(cherryPickResult.cherryPickOfPatchSet).isNull();
+    // Expect that the message of the cherry-picked commit was used for the cherry-pick change.
+    RevisionInfo revInfo = cherryPickResult.revisions.get(cherryPickResult.currentRevision);
+    assertThat(revInfo).isNotNull();
+    assertThat(revInfo.commit.message).isEqualTo(commitToCherryPick.getFullMessage());
+  }
+
+  @Test
+  public void cherryPickCommitWithoutChangeIdCreateNewChange() throws Exception {
+    String destBranch = "foo";
+    createBranch(BranchNameKey.create(project, destBranch));
+
+    CherryPickInput input = new CherryPickInput();
+    input.destination = destBranch;
     input.message = "it goes to foo branch";
-    gApi.projects().name(project.get()).branch(input.destination).create(new BranchInput());
 
-    RevCommit revCommit = createNewCommitWithoutChangeId("refs/heads/master", "a.txt", "content");
-    ChangeInfo changeInfo =
-        gApi.projects().name(project.get()).commit(revCommit.getName()).cherryPick(input).get();
+    RevCommit commitToCherryPick =
+        createNewCommitWithoutChangeId("refs/heads/master", "a.txt", "content");
+    ChangeInfo cherryPickResult =
+        gApi.projects()
+            .name(project.get())
+            .commit(commitToCherryPick.getName())
+            .cherryPick(input)
+            .get();
 
-    assertThat(changeInfo.messages).hasSize(1);
-    Iterator<ChangeMessageInfo> messageIterator = changeInfo.messages.iterator();
+    assertThat(cherryPickResult.messages).hasSize(1);
+    Iterator<ChangeMessageInfo> messageIterator = cherryPickResult.messages.iterator();
     String expectedMessage =
-        String.format("Patch Set 1: Cherry Picked from commit %s.", revCommit.getName());
+        String.format("Patch Set 1: Cherry Picked from commit %s.", commitToCherryPick.getName());
     assertThat(messageIterator.next().message).isEqualTo(expectedMessage);
 
-    RevisionInfo revInfo = changeInfo.revisions.get(changeInfo.currentRevision);
+    RevisionInfo revInfo = cherryPickResult.revisions.get(cherryPickResult.currentRevision);
     assertThat(revInfo).isNotNull();
     CommitInfo commitInfo = revInfo.commit;
     assertThat(commitInfo.message)
-        .isEqualTo(input.message + "\n\nChange-Id: " + changeInfo.changeId + "\n");
+        .isEqualTo(input.message + "\n\nChange-Id: " + cherryPickResult.changeId + "\n");
   }
 
   @Test
-  public void cherryPickCommitWithChangeId() throws Exception {
-    CherryPickInput input = new CherryPickInput();
-    input.destination = "foo";
+  public void cherryPickCommitWithChangeIdCreateNewChange() throws Exception {
+    String destBranch = "foo";
+    createBranch(BranchNameKey.create(project, destBranch));
 
-    RevCommit revCommit = createChange().getCommit();
-    List<String> footers = revCommit.getFooterLines("Change-Id");
+    PushOneCommit.Result r = createChange();
+    ChangeInfo changeToCherryPick = info(r.getChangeId());
+    RevCommit commitToCherryPick = r.getCommit();
+    List<String> footers = commitToCherryPick.getFooterLines("Change-Id");
     assertThat(footers).hasSize(1);
     String changeId = footers.get(0);
 
-    input.message = "it goes to foo branch\n\nChange-Id: " + changeId;
-    gApi.projects().name(project.get()).branch(input.destination).create(new BranchInput());
+    CherryPickInput input = new CherryPickInput();
+    input.destination = destBranch;
+    input.message =
+        String.format(
+            "it goes to foo branch\n\nChange-Id: Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef\n\nChange-Id: %s\n",
+            changeId);
 
-    ChangeInfo changeInfo =
-        gApi.projects().name(project.get()).commit(revCommit.getName()).cherryPick(input).get();
+    ChangeInfo cherryPickResult =
+        gApi.projects()
+            .name(project.get())
+            .commit(commitToCherryPick.getName())
+            .cherryPick(input)
+            .get();
 
-    assertThat(changeInfo.messages).hasSize(1);
-    Iterator<ChangeMessageInfo> messageIterator = changeInfo.messages.iterator();
+    // No change was found in destination branch with the provided Change-Id.
+    assertThat(cherryPickResult._number).isGreaterThan(changeToCherryPick._number);
+    assertThat(cherryPickResult.changeId).isEqualTo(changeId);
+    assertThat(cherryPickResult.revisions).hasSize(1);
+    assertThat(cherryPickResult.messages).hasSize(1);
+    Iterator<ChangeMessageInfo> messageIterator = cherryPickResult.messages.iterator();
     String expectedMessage =
-        String.format("Patch Set 1: Cherry Picked from commit %s.", revCommit.getName());
+        String.format("Patch Set 1: Cherry Picked from commit %s.", commitToCherryPick.getName());
     assertThat(messageIterator.next().message).isEqualTo(expectedMessage);
 
-    RevisionInfo revInfo = changeInfo.revisions.get(changeInfo.currentRevision);
+    // Cherry-pick of is not set, because the source change was not provided.
+    assertThat(cherryPickResult.cherryPickOfChange).isNull();
+    assertThat(cherryPickResult.cherryPickOfPatchSet).isNull();
+    RevisionInfo revInfo = cherryPickResult.revisions.get(cherryPickResult.currentRevision);
     assertThat(revInfo).isNotNull();
-    assertThat(revInfo.commit.message).isEqualTo(input.message + "\n");
+    assertThat(revInfo.commit.message).isEqualTo(input.message);
+  }
+
+  @Test
+  public void cherryPickCommitToExistingChange() throws Exception {
+    String destBranch = "foo";
+    createBranch(BranchNameKey.create(project, destBranch));
+
+    PushOneCommit.Result r = createChange("refs/for/" + destBranch);
+    ChangeInfo existingDestChange = info(r.getChangeId());
+
+    String commitToCherryPick = createChange().getCommit().getName();
+
+    CherryPickInput input = new CherryPickInput();
+    input.destination = destBranch;
+    input.message =
+        String.format(
+            "it goes to foo branch\n\nChange-Id: Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef\n\nChange-Id: %s\n",
+            existingDestChange.changeId);
+    input.allowConflicts = true;
+    input.allowEmpty = true;
+
+    ChangeInfo cherryPickResult =
+        gApi.projects().name(project.get()).commit(commitToCherryPick).cherryPick(input).get();
+
+    // New patch-set to existing change was uploaded.
+    assertThat(cherryPickResult._number).isEqualTo(existingDestChange._number);
+    assertThat(cherryPickResult.changeId).isEqualTo(existingDestChange.changeId);
+    assertThat(cherryPickResult.messages).hasSize(2);
+    assertThat(cherryPickResult.revisions).hasSize(2);
+    Iterator<ChangeMessageInfo> messageIterator = cherryPickResult.messages.iterator();
+
+    assertThat(messageIterator.next().message).isEqualTo("Uploaded patch set 1.");
+    assertThat(messageIterator.next().message).isEqualTo("Uploaded patch set 2.");
+    // Cherry-pick of is not set, because the source change was not provided.
+    assertThat(cherryPickResult.cherryPickOfChange).isNull();
+    assertThat(cherryPickResult.cherryPickOfPatchSet).isNull();
+    RevisionInfo revInfo = cherryPickResult.revisions.get(cherryPickResult.currentRevision);
+    assertThat(revInfo).isNotNull();
+    assertThat(revInfo.commit.message).isEqualTo(input.message);
+  }
+
+  @Test
+  public void cherryPickCommitToExistingCherryPickedChange() throws Exception {
+    String destBranch = "foo";
+    createBranch(BranchNameKey.create(project, destBranch));
+
+    PushOneCommit.Result r = createChange("refs/for/" + destBranch);
+    ChangeInfo existingDestChange = info(r.getChangeId());
+
+    r = createChange();
+    ChangeInfo changeToCherryPick = info(r.getChangeId());
+    RevCommit commitToCherryPick = r.getCommit();
+
+    CherryPickInput input = new CherryPickInput();
+    input.destination = destBranch;
+    input.message =
+        String.format("it goes to foo branch\n\nChange-Id: %s\n", existingDestChange.changeId);
+    input.allowConflicts = true;
+    input.allowEmpty = true;
+    // Use RevisionAPI to submit initial cherryPick.
+    ChangeInfo cherryPickResult =
+        gApi.changes().id(changeToCherryPick.changeId).current().cherryPick(input).get();
+    assertThat(cherryPickResult.changeId).isEqualTo(existingDestChange.changeId);
+    // Cherry-pick was set.
+    assertThat(cherryPickResult.cherryPickOfChange).isEqualTo(changeToCherryPick._number);
+    assertThat(cherryPickResult.cherryPickOfPatchSet).isEqualTo(1);
+    RevisionInfo revInfo = cherryPickResult.revisions.get(cherryPickResult.currentRevision);
+    assertThat(revInfo).isNotNull();
+    assertThat(revInfo.commit.message).isEqualTo(input.message);
+    // Use CommitApi to update the cherryPick change.
+    cherryPickResult =
+        gApi.projects()
+            .name(project.get())
+            .commit(commitToCherryPick.getName())
+            .cherryPick(input)
+            .get();
+
+    assertThat(cherryPickResult.changeId).isEqualTo(existingDestChange.changeId);
+    assertThat(cherryPickResult.messages).hasSize(3);
+    Iterator<ChangeMessageInfo> messageIterator = cherryPickResult.messages.iterator();
+
+    assertThat(messageIterator.next().message).isEqualTo("Uploaded patch set 1.");
+    assertThat(messageIterator.next().message).isEqualTo("Uploaded patch set 2.");
+    assertThat(messageIterator.next().message).isEqualTo("Uploaded patch set 3.");
+    // Cherry-pick was reset to empty value.
+    assertThat(cherryPickResult._number).isEqualTo(existingDestChange._number);
+    assertThat(cherryPickResult.cherryPickOfChange).isNull();
+    assertThat(cherryPickResult.cherryPickOfPatchSet).isNull();
+  }
+
+  @Test
+  public void cherryPickCommitWithChangeIdToClosedChange() throws Exception {
+    String destBranch = "refs/heads/foo";
+    createBranch(BranchNameKey.create(project, destBranch));
+
+    PushOneCommit.Result r = createChange("refs/for/" + destBranch);
+    ChangeInfo existingDestChange = info(r.getChangeId());
+    String commitToCherryPick = createChange().getCommit().getName();
+
+    gApi.changes().id(existingDestChange.changeId).current().review(ReviewInput.approve());
+    gApi.changes().id(existingDestChange.changeId).current().submit();
+
+    CherryPickInput input = new CherryPickInput();
+    input.destination = destBranch;
+    input.message =
+        String.format("it goes to foo branch\n\nChange-Id: %s\n", existingDestChange.changeId);
+    input.allowConflicts = true;
+    input.allowEmpty = true;
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).commit(commitToCherryPick).cherryPick(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Cherry-pick with Change-Id %s could not update the existing change %d in "
+                    + "destination branch %s of project %s, because the change was closed (MERGED)",
+                existingDestChange.changeId,
+                existingDestChange._number,
+                destBranch,
+                project.get()));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index d5fc1c1..6e19ac2 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -43,6 +43,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
@@ -882,7 +883,7 @@
     cfg.fromText(projectOperations.project(allProjects).getConfig().toText());
     cfg.setStringList(
         "label",
-        "Code-Review",
+        LabelId.CODE_REVIEW,
         "value",
         ImmutableList.of("+1 LGTM", "1 LGTM", "0 No Value", "-1 Looks Bad"));
 
@@ -909,7 +910,7 @@
             cfg ->
                 cfg.setStringList(
                     "label",
-                    "Code-Review",
+                    LabelId.CODE_REVIEW,
                     "value",
                     ImmutableList.of("+1 LGTM", "1 LGTM", "0 No Value", "-1 Looks Bad")))
         .invalidate();
@@ -917,8 +918,8 @@
     // Verify that project info can be retrieved and that the label value "+1 LGTM" appears only
     // once.
     ProjectInfo projectInfo = gApi.projects().name(allProjects.get()).get();
-    assertThat(projectInfo.labels.keySet()).containsExactly("Code-Review");
-    assertThat(projectInfo.labels.get("Code-Review").values)
+    assertThat(projectInfo.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertThat(projectInfo.labels.get(LabelId.CODE_REVIEW).values)
         .containsExactly("+1", "LGTM", " 0", "No Value", "-1", "Looks Bad");
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/BUILD b/javatests/com/google/gerrit/acceptance/api/revision/BUILD
index 3bfe2f0..517b041 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/revision/BUILD
@@ -4,4 +4,12 @@
     srcs = [f],
     group = f[:f.index(".")],
     labels = ["api"],
+    deps = [":revision-diff-it"],
 ) for f in glob(["*IT.java"])]
+
+# This is needed because RevisionDiffIT has subclasses that depend on it
+java_library(
+    name = "revision-diff-it",
+    srcs = ["RevisionDiffIT.java"],
+    deps = ["//java/com/google/gerrit/acceptance:lib"],
+)
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 2976d78..68bb66c 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -42,7 +42,6 @@
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.testing.ConfigSuite;
 import java.awt.image.BufferedImage;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
@@ -55,7 +54,6 @@
 import java.util.function.Function;
 import java.util.stream.IntStream;
 import javax.imageio.ImageIO;
-import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -66,7 +64,8 @@
 public class RevisionDiffIT extends AbstractDaemonTest {
   // @RunWith(Parameterized.class) can't be used as AbstractDaemonTest is annotated with another
   // runner. Using different configs is a workaround to achieve the same.
-  private static final String TEST_PARAMETER_MARKER = "test_only_parameter";
+  protected static final String TEST_PARAMETER_MARKER = "test_only_parameter";
+
   private static final String CURRENT = "current";
   private static final String FILE_NAME = "some_file.txt";
   private static final String FILE_NAME2 = "another_file.txt";
@@ -77,17 +76,12 @@
   private static final String FILE_CONTENT2 = "1st line\n2nd line\n3rd line\n";
 
   private boolean intraline;
+  private boolean useNewDiffCache;
+
   private ObjectId commit1;
   private String changeId;
   private String initialPatchSetId;
 
-  @ConfigSuite.Config
-  public static Config intralineConfig() {
-    Config config = new Config();
-    config.setBoolean(TEST_PARAMETER_MARKER, null, "intraline", true);
-    return config;
-  }
-
   @Before
   public void setUp() throws Exception {
     // Reduce flakiness of tests. (If tests aren't fast enough, we would use a fall-back
@@ -96,6 +90,7 @@
     baseConfig.setString("cache", "diff_intraline", "timeout", "1 minute");
 
     intraline = baseConfig.getBoolean(TEST_PARAMETER_MARKER, "intraline", false);
+    useNewDiffCache = baseConfig.getBoolean("cache", "diff_cache", "useNewDiffCache", false);
 
     ObjectId headCommit = testRepo.getRepository().resolve("HEAD");
     commit1 =
@@ -118,6 +113,12 @@
     assertDiffForNewFile(result, COMMIT_MSG, result.getCommit().getFullMessage());
   }
 
+  @Ignore
+  @Test
+  public void diffWithRootCommit() throws Exception {
+    // TODO(ghareeb): Implement this test
+  }
+
   @Test
   public void patchsetLevelFileDiffIsEmpty() throws Exception {
     PushOneCommit.Result result = createChange();
@@ -446,6 +447,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 diffWithThreeParentsMergeCommitAgainstAutoMergeReturnsCommitMsgAndMergeListOnly()
+      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
+    Map<String, FileInfo> changedFiles = gApi.changes().id(r.getChangeId()).current().files();
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, MERGE_LIST);
+  }
+
+  @Test
   public void diffBetweenPatchSetsOfMergeCommitCanBeRetrievedForCommitMessageAndMergeList()
       throws Exception {
     PushOneCommit.Result result = createMergeCommitChange("refs/for/master", "my_file.txt");
@@ -874,6 +926,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();
@@ -1189,6 +1272,9 @@
   @Test
   public void renamedUnrelatedFileIsIgnored_ForPatchSetDiffWithRebase_WhenEquallyModifiedInBoth()
       throws Exception {
+    // TODO(ghareeb): fix this test for the new diff cache implementation
+    assume().that(useNewDiffCache).isFalse();
+
     Function<String, String> contentModification =
         fileContent -> fileContent.replace("1st line\n", "First line\n");
     addModifiedPatchSet(changeId, FILE_NAME2, contentModification);
@@ -1279,6 +1365,9 @@
 
   @Test
   public void filesTouchedByPatchSetsAndContainingOnlyRebaseHunksAreIgnored() throws Exception {
+    // TODO(ghareeb): fix this test for the new diff cache implementation
+    assume().that(useNewDiffCache).isFalse();
+
     addModifiedPatchSet(
         changeId, FILE_NAME, fileContent -> fileContent.replace("Line 50\n", "Line fifty\n"));
     addModifiedPatchSet(
@@ -2657,6 +2746,57 @@
   }
 
   @Test
+  public void symlinkConvertedToRegularFileIsIdentifiedAsAdded() throws Exception {
+    // TODO(ghareeb): fix this test for the new diff cache implementation
+    assume().that(useNewDiffCache).isFalse();
+
+    String target = "file.txt";
+    String symlink = "link.lnk";
+
+    // Create a change adding file "FileName" and a symlink "symLink" pointing to the file
+    PushOneCommit push =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Commit Subject", target, "content")
+            .addSymlink(symlink, target);
+
+    PushOneCommit.Result result = push.to("refs/for/master");
+    String initialRev = gApi.changes().id(result.getChangeId()).get().currentRevision;
+
+    // Delete the symlink with patchset 2
+    gApi.changes().id(result.getChangeId()).edit().deleteFile(symlink);
+    gApi.changes().id(result.getChangeId()).edit().publish();
+
+    // Re-add the symlink as a regular file with patchset 3
+    gApi.changes()
+        .id(result.getChangeId())
+        .edit()
+        .modifyFile(symlink, RawInputUtil.create("Content of the new file named 'symlink'"));
+    gApi.changes().id(result.getChangeId()).edit().publish();
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(result.getChangeId()).current().files(initialRev);
+
+    assertThat(changedFiles.keySet()).containsExactly("/COMMIT_MSG", symlink);
+    assertThat(changedFiles.get(symlink).status).isEqualTo('W'); // Rewrite
+
+    DiffInfo diffInfo =
+        gApi.changes().id(result.getChangeId()).current().file(symlink).diff(initialRev);
+
+    // The diff logic identifies two entries for the file:
+    // 1. One entry as 'DELETED' for the symlink.
+    // 2. Another entry as 'ADDED' for the new regular file.
+    // Since the diff logic returns a single entry, we prioritize returning the 'ADDED' entry in
+    // this case so that the client is able to see the new content that was added to the file.
+    assertThat(diffInfo.changeType).isEqualTo(ChangeType.ADDED);
+    assertThat(diffInfo.content).hasSize(1);
+    assertThat(diffInfo)
+        .content()
+        .element(0)
+        .linesOfB()
+        .containsExactly("Content of the new file named 'symlink'");
+  }
+
+  @Test
   public void diffOfNonExistentFileIsAnEmptyDiffResult() throws Exception {
     addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
 
@@ -2843,4 +2983,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/RevisionDiffIntralineIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIntralineIT.java
new file mode 100644
index 0000000..ff4ac8e
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIntralineIT.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.revision;
+
+import com.google.gerrit.testing.ConfigSuite;
+import org.eclipse.jgit.lib.Config;
+
+/** Runs the {@link RevisionDiffIT} tests with the intraline config enabled. */
+public class RevisionDiffIntralineIT extends RevisionDiffIT {
+  @ConfigSuite.Default
+  public static Config intralineConfig() {
+    Config config = new Config();
+    config.setBoolean(TEST_PARAMETER_MARKER, null, "intraline", true);
+    return config;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 2f9530c..abfd7896 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -54,6 +54,7 @@
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.BranchOrderSection;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
@@ -87,15 +88,12 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.ETagView;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
-import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.restapi.change.GetRevisionActions;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.inject.Inject;
 import java.io.ByteArrayOutputStream;
@@ -121,7 +119,6 @@
 import org.junit.Test;
 
 public class RevisionIT extends AbstractDaemonTest {
-  @Inject private GetRevisionActions getRevisionActions;
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ExtensionRegistry extensionRegistry;
@@ -165,7 +162,7 @@
     String changeId = project.get() + "~master~" + r.getChangeId();
     gApi.changes().id(changeId).current().review(ReviewInput.recommend());
 
-    String label = "Code-Review";
+    String label = LabelId.CODE_REVIEW;
     ApprovalInfo approval = getApproval(changeId, label);
     assertThat(approval.value).isEqualTo(1);
     assertThat(approval.postSubmit).isNull();
@@ -177,10 +174,10 @@
     approval = getApproval(changeId, label);
     assertThat(approval.value).isEqualTo(1);
     assertThat(approval.postSubmit).isNull();
-    assertPermitted(gApi.changes().id(changeId).get(DETAILED_LABELS), "Code-Review", 1, 2);
+    assertPermitted(gApi.changes().id(changeId).get(DETAILED_LABELS), LabelId.CODE_REVIEW, 1, 2);
 
-    // Repeating the current label is allowed. Does not flip the postSubmit bit
-    // due to deduplication codepath.
+    // Repeating the current label is allowed. Does not flip the postSubmit bit due to
+    // deduplication codepath.
     gApi.changes().id(changeId).current().review(ReviewInput.recommend());
     approval = getApproval(changeId, label);
     assertThat(approval.value).isEqualTo(1);
@@ -203,7 +200,7 @@
     approval = getApproval(changeId, label);
     assertThat(approval.value).isEqualTo(2);
     assertThat(approval.postSubmit).isTrue();
-    assertPermitted(gApi.changes().id(changeId).get(DETAILED_LABELS), "Code-Review", 2);
+    assertPermitted(gApi.changes().id(changeId).get(DETAILED_LABELS), LabelId.CODE_REVIEW, 2);
 
     // Decreasing to previous post-submit vote is still not allowed.
     thrown =
@@ -230,9 +227,9 @@
     revision(r).review(ReviewInput.recommend());
 
     requestScopeOperations.setApiUser(admin.id());
-    gApi.changes().id(changeId).reviewer(user.username()).deleteVote("Code-Review");
+    gApi.changes().id(changeId).reviewer(user.username()).deleteVote(LabelId.CODE_REVIEW);
     Optional<ApprovalInfo> crUser =
-        get(changeId, DETAILED_LABELS).labels.get("Code-Review").all.stream()
+        get(changeId, DETAILED_LABELS).labels.get(LabelId.CODE_REVIEW).all.stream()
             .filter(a -> a._accountId == user.id().get())
             .findFirst();
     assertThat(crUser).isPresent();
@@ -242,12 +239,13 @@
 
     requestScopeOperations.setApiUser(user.id());
     ReviewInput in = new ReviewInput();
-    in.label("Code-Review", 1);
+    in.label(LabelId.CODE_REVIEW, 1);
     in.message = "Still LGTM";
     revision(r).review(in);
 
     ApprovalInfo cr =
-        gApi.changes().id(changeId).get(DETAILED_LABELS).labels.get("Code-Review").all.stream()
+        gApi.changes().id(changeId).get(DETAILED_LABELS).labels.get(LabelId.CODE_REVIEW).all
+            .stream()
             .filter(a -> a._accountId == user.id().get())
             .findFirst()
             .get();
@@ -262,7 +260,7 @@
     revision(r).submit();
 
     ReviewInput in = new ReviewInput();
-    in.label("Code-Review", 0);
+    in.label(LabelId.CODE_REVIEW, 0);
 
     ResourceConflictException thrown =
         assertThrows(ResourceConflictException.class, () -> revision(r).review(in));
@@ -285,7 +283,7 @@
         Iterators.getOnlyElement(
             cd.currentApprovals().stream().filter(a -> !a.isLegacySubmit()).iterator());
     assertThat(psa.patchSetId().get()).isEqualTo(2);
-    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa.value()).isEqualTo(2);
     assertThat(psa.postSubmit()).isFalse();
   }
@@ -386,8 +384,11 @@
 
     // The cherry-pick honors the ChangeId specified in the input message:
     RevisionInfo revInfo = changeInfo.revisions.get(changeInfo.currentRevision);
+    // New change was created.
+    assertThat(changeInfo._number).isGreaterThan(orig.get()._number);
+    assertThat(changeInfo.changeId).isEqualTo(id);
     assertThat(revInfo).isNotNull();
-    assertThat(revInfo.commit.message).endsWith(id + "\n");
+    assertThat(revInfo.commit.message.trim()).endsWith(id);
   }
 
   @Test
@@ -477,6 +478,9 @@
     assertThat(cherryInfo.messages).hasSize(2);
     Iterator<ChangeMessageInfo> cherryIt = cherryInfo.messages.iterator();
     assertThat(cherryInfo.cherryPickOfChange).isEqualTo(change.get()._number);
+
+    // Existing change was updated.
+    assertThat(cherryInfo._number).isEqualTo(change.get()._number);
     assertThat(cherryInfo.cherryPickOfPatchSet).isEqualTo(1);
     assertThat(cherryIt.next().message).isEqualTo("Uploaded patch set 1.");
     assertThat(cherryIt.next().message).isEqualTo("Uploaded patch set 2.");
@@ -708,12 +712,13 @@
     in.destination = "foo";
     in.message = r3.getCommit().getFullMessage();
     cherry = gApi.changes().id(t1).current().cherryPick(in);
+    assertThat(cherry.get()._number).isEqualTo(info(t2)._number);
     assertThat(cherry.get().cherryPickOfChange).isEqualTo(orig.get()._number);
     assertThat(cherry.get().cherryPickOfPatchSet).isEqualTo(2);
   }
 
   @Test
-  public void cherryPickToExistingChange() throws Exception {
+  public void cherryPickToAbandonedChange() throws Exception {
     PushOneCommit.Result r1 =
         pushFactory
             .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "a")
@@ -734,15 +739,17 @@
     CherryPickInput in = new CherryPickInput();
     in.destination = "foo";
     in.message = r1.getCommit().getFullMessage();
-    ResourceConflictException thrown =
+    BadRequestException thrown =
         assertThrows(
-            ResourceConflictException.class, () -> gApi.changes().id(t1).current().cherryPick(in));
+            BadRequestException.class, () -> gApi.changes().id(t1).current().cherryPick(in));
     assertThat(thrown)
         .hasMessageThat()
         .isEqualTo(
-            "Cannot create new patch set of change "
-                + info(t2)._number
-                + " because it is abandoned");
+            String.format(
+                "Cherry-pick with Change-Id %s could not update the existing change %d in "
+                    + "destination branch refs/heads/foo of project %s, because "
+                    + "the change was closed (ABANDONED)",
+                r1.getChangeId(), info(t2)._number, project.get()));
 
     gApi.changes().id(t2).restore();
     gApi.changes().id(t1).current().cherryPick(in);
@@ -751,6 +758,44 @@
   }
 
   @Test
+  public void cherryPickToExistingMergedChange() throws Exception {
+    PushOneCommit.Result r1 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "a")
+            .to("refs/for/master");
+
+    BranchInput bin = new BranchInput();
+    bin.revision = r1.getCommit().getParent(0).name();
+    gApi.projects().name(project.get()).branch("foo").create(bin);
+
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "b", r1.getChangeId())
+            .to("refs/for/foo");
+    String t2 = project.get() + "~foo~" + r2.getChangeId();
+
+    gApi.changes().id(t2).current().review(ReviewInput.approve());
+    gApi.changes().id(t2).current().submit();
+
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "foo";
+    in.message = r1.getCommit().getFullMessage();
+    in.allowConflicts = true;
+    in.allowEmpty = true;
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> gApi.changes().id(t2).current().cherryPick(in));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Cherry-pick with Change-Id %s could not update the existing change %d in "
+                    + "destination branch refs/heads/foo of project %s, because "
+                    + "the change was closed (MERGED)",
+                r1.getChangeId(), info(t2)._number, project.get()));
+  }
+
+  @Test
   public void cherryPickMergeRelativeToDefaultParent() throws Exception {
     String parent1FileName = "a.txt";
     String parent2FileName = "b.txt";
@@ -872,7 +917,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 +927,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";
@@ -1573,7 +1618,8 @@
     PatchSetWebLink link =
         new PatchSetWebLink() {
           @Override
-          public WebLinkInfo getPatchSetWebLink(String projectName, String commit) {
+          public WebLinkInfo getPatchSetWebLink(
+              String projectName, String commit, String commitMessage, String branchName) {
             return expectedWebLinkInfo;
           }
         };
@@ -1778,23 +1824,6 @@
   }
 
   @Test
-  public void actionsETag() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    PushOneCommit.Result r2 = createChange();
-
-    String oldETag = checkETag(getRevisionActions, r2, null);
-    current(r2).review(ReviewInput.approve());
-    oldETag = checkETag(getRevisionActions, r2, oldETag);
-
-    // Dependent change is included in ETag.
-    current(r1).review(ReviewInput.approve());
-    oldETag = checkETag(getRevisionActions, r2, oldETag);
-
-    current(r2).submit();
-    checkETag(getRevisionActions, r2, oldETag);
-  }
-
-  @Test
   public void deleteVoteOnNonCurrentPatchSet() throws Exception {
     PushOneCommit.Result r = createChange(); // patch set 1
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
@@ -1816,7 +1845,7 @@
                     .id(r.getChangeId())
                     .revision(r.getCommit().getName())
                     .reviewer(user.id().toString())
-                    .deleteVote("Code-Review"));
+                    .deleteVote(LabelId.CODE_REVIEW));
     assertThat(thrown).hasMessageThat().contains("Cannot access on non-current patch set");
   }
 
@@ -1837,12 +1866,12 @@
         .id(r.getChangeId())
         .current()
         .reviewer(user.id().toString())
-        .deleteVote("Code-Review");
+        .deleteVote(LabelId.CODE_REVIEW);
 
     Map<String, Short> m =
         gApi.changes().id(r.getChangeId()).current().reviewer(user.id().toString()).votes();
 
-    assertThat(m).containsExactly("Code-Review", Short.valueOf((short) 0));
+    assertThat(m).containsExactly(LabelId.CODE_REVIEW, Short.valueOf((short) 0));
 
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
     ChangeMessageInfo message = Iterables.getLast(c.messages);
@@ -1860,8 +1889,8 @@
     assertThat(votes).isEmpty();
     recommend(changeId);
     votes = gApi.changes().id(changeId).current().votes();
-    assertThat(votes.keySet()).containsExactly("Code-Review");
-    List<ApprovalInfo> approvals = votes.get("Code-Review");
+    assertThat(votes.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    List<ApprovalInfo> approvals = votes.get(LabelId.CODE_REVIEW);
     assertThat(approvals).hasSize(1);
     ApprovalInfo approval = approvals.get(0);
     assertThat(approval._accountId).isEqualTo(admin.id().get());
@@ -1875,8 +1904,8 @@
     // Patch set 1 has 2 votes on Code-Review
     requestScopeOperations.setApiUser(admin.id());
     votes = gApi.changes().id(changeId).current().votes();
-    assertThat(votes.keySet()).containsExactly("Code-Review");
-    approvals = votes.get("Code-Review");
+    assertThat(votes.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    approvals = votes.get(LabelId.CODE_REVIEW);
     assertThat(approvals).hasSize(2);
     assertThat(approvals.stream().map(a -> a._accountId))
         .containsExactlyElementsIn(ImmutableList.of(admin.id().get(), user.id().get()));
@@ -1888,8 +1917,8 @@
 
     // Votes are still returned for ps 1
     votes = gApi.changes().id(changeId).revision(1).votes();
-    assertThat(votes.keySet()).containsExactly("Code-Review");
-    approvals = votes.get("Code-Review");
+    assertThat(votes.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    approvals = votes.get(LabelId.CODE_REVIEW);
     assertThat(approvals).hasSize(2);
   }
 
@@ -1965,13 +1994,6 @@
     return gApi.changes().id(r.getChangeId()).current();
   }
 
-  private String checkETag(ETagView<RevisionResource> view, PushOneCommit.Result r, String oldETag)
-      throws Exception {
-    String eTag = view.getETag(parseRevisionResource(r));
-    assertThat(eTag).isNotEqualTo(oldETag);
-    return eTag;
-  }
-
   private PushOneCommit.Result createCherryPickableMerge(
       String parent1FileName, String parent2FileName) throws Exception {
     RevCommit initialCommit = getHead(repo(), "HEAD");
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionNewDiffCacheIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionNewDiffCacheIT.java
new file mode 100644
index 0000000..4b85c30
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionNewDiffCacheIT.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.revision;
+
+import com.google.gerrit.testing.ConfigSuite;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Runs the {@link RevisionDiffIT} tests with the new diff cache. This is temporary until the new
+ * diff cache is fully deployed. The new diff cache will become the default in the future.
+ */
+public class RevisionNewDiffCacheIT extends RevisionDiffIT {
+  @ConfigSuite.Default
+  public static Config newDiffCacheConfig() {
+    Config config = new Config();
+    config.setBoolean("cache", "diff_cache", "useNewDiffCache", true);
+    return config;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index 5808ea4..8233f0c 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
@@ -804,7 +805,7 @@
 
   @Test
   public void editCommitMessageCopiesLabelScores() throws Exception {
-    String cr = "Code-Review";
+    String cr = LabelId.CODE_REVIEW;
     try (ProjectConfigUpdate u = updateProject(project)) {
       LabelType codeReview = TestLabels.codeReview();
       u.getConfig().upsertLabelType(codeReview);
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractGitOverHttpServlet.java b/javatests/com/google/gerrit/acceptance/git/AbstractGitOverHttpServlet.java
index 35f8270..a22759f 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractGitOverHttpServlet.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractGitOverHttpServlet.java
@@ -19,6 +19,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.FakeGroupAuditService;
 import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.pgm.http.jetty.JettyServer;
 import com.google.gerrit.server.audit.HttpAuditEvent;
@@ -27,7 +28,6 @@
 import javax.servlet.http.HttpServletResponse;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.transport.CredentialsProvider;
 import org.eclipse.jgit.transport.RefSpec;
@@ -48,6 +48,10 @@
     // Don't clear audit events here, since we can't guarantee all test setup has run yet.
   }
 
+  /**
+   * As of today only fetch Protocol V2 is supported on the git client.
+   * https://git.eclipse.org/r/c/jgit/jgit/+/172595
+   */
   @Test
   @Sandboxed
   public void receivePackAuditEventLog() throws Exception {
@@ -77,11 +81,7 @@
   }
 
   @Test
-  public void anonymousUploadPackAuditEventLog() throws Exception {
-    uploadPackAuditEventLog(Constants.DEFAULT_REMOTE_NAME, Optional.empty());
-  }
-
-  @Test
+  @TestProjectInput(createEmptyCommit = false)
   public void authenticatedUploadPackAuditEventLog() throws Exception {
     String remote = "authenticated";
     Config cfg = testRepo.git().getRepository().getConfig();
@@ -93,34 +93,72 @@
     uploadPackAuditEventLog(remote, Optional.of(admin.id()));
   }
 
+  @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void anonymousUploadPackAuditEventLog() throws Exception {
+    String remote = "anonymous";
+    Config cfg = testRepo.git().getRepository().getConfig();
+
+    String uri = server.getUrl() + "/" + project.get();
+    cfg.setString("remote", remote, "url", uri);
+    cfg.setString("remote", remote, "fetch", "+refs/heads/*:refs/remotes/origin/*");
+
+    uploadPackAuditEventLog(remote, Optional.empty());
+  }
+
+  /**
+   * Git client use Protocol V2 fetch by default, see https://git.eclipse.org/r/c/jgit/jgit/+/172595
+   * See {@code org.eclipse.jgit.transport.BasePackFetchConnection#doFetchV2} for the negotiation
+   * details.
+   */
   private void uploadPackAuditEventLog(String remote, Optional<Account.Id> accountId)
       throws Exception {
+    // Make a server-side change to have a common base.
+    createCommit("foo");
+    testRepo.git().fetch().call();
+
+    // Make a server-side change so we have something to fetch.
+    createCommit("bar");
+
     auditService.drainHttpAuditEvents();
-    // testRepo is already a clone. Make a server-side change so we have something to fetch.
-    try (Repository repo = repoManager.openRepository(project);
-        TestRepository<?> testRepo = new TestRepository<>(repo)) {
-      testRepo.branch("master").commit().create();
-    }
     testRepo.git().fetch().setRemote(remote).call();
 
     ImmutableList<HttpAuditEvent> auditEvents = auditService.drainHttpAuditEvents();
-    assertThat(auditEvents).hasSize(2);
+    assertThat(auditEvents).hasSize(3);
 
-    HttpAuditEvent lsRemote = auditEvents.get(0);
-    assertThat(lsRemote.who.toString())
+    // Protocol V2 Capability advertisement
+    // https://git-scm.com/docs/protocol-v2#_capability_advertisement
+    HttpAuditEvent infoRef = auditEvents.get(0);
+
+    assertThat(infoRef.who.toString())
         .isEqualTo(
             accountId.map(id -> "IdentifiedUser[account " + id.get() + "]").orElse("ANONYMOUS"));
-    assertThat(lsRemote.what).endsWith("/info/refs?service=git-upload-pack");
-    assertThat(lsRemote.params).containsExactly("service", "git-upload-pack");
-    assertThat(lsRemote.httpStatus).isEqualTo(HttpServletResponse.SC_OK);
+    assertThat(infoRef.what).endsWith("/info/refs?service=git-upload-pack");
+    assertThat(infoRef.params).containsExactly("service", "git-upload-pack");
+    assertThat(infoRef.httpStatus).isEqualTo(HttpServletResponse.SC_OK);
 
-    HttpAuditEvent uploadPack = auditEvents.get(1);
-    assertThat(uploadPack.who.toString())
-        .isEqualTo(
-            accountId.map(id -> "IdentifiedUser[account " + id.get() + "]").orElse("ANONYMOUS"));
-    assertThat(uploadPack.what).endsWith("/git-upload-pack");
-    assertThat(uploadPack.params).isEmpty();
-    assertThat(uploadPack.httpStatus).isEqualTo(HttpServletResponse.SC_OK);
+    // Smart service negotiations, as described here
+    // https://git-scm.com/docs/http-protocol#_smart_service_git_upload_pack
+    // Protocol V2 client sends command=ls-ref https://git-scm.com/docs/protocol-v2#_ls_refs
+    // followed by command=fetch, thus the request may overflow see
+    // org.eclipse.jgit.transport.MultiRequestService
+    HttpAuditEvent uploadPackLsRef = auditEvents.get(1);
+
+    assertThat(uploadPackLsRef.what).endsWith("/git-upload-pack");
+    assertThat(uploadPackLsRef.params).isEmpty();
+    assertThat(uploadPackLsRef.httpStatus).isEqualTo(HttpServletResponse.SC_OK);
+    HttpAuditEvent uploadPackFetch = auditEvents.get(2);
+
+    assertThat(uploadPackFetch.what).endsWith("/git-upload-pack");
+    assertThat(uploadPackFetch.params).isEmpty();
+    assertThat(uploadPackFetch.httpStatus).isEqualTo(HttpServletResponse.SC_OK);
     assertThat(jettyServer.numActiveSessions()).isEqualTo(0);
   }
+
+  private void createCommit(String message) throws Exception {
+    try (Repository repo = repoManager.openRepository(project);
+        TestRepository<Repository> tr = new TestRepository<>(repo)) {
+      tr.branch("master").commit().message(message).create();
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index bb1a2eb..845c461 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -68,6 +68,7 @@
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Permission;
@@ -87,12 +88,14 @@
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.TopicEditedListener;
 import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.events.CommitReceivedEvent;
@@ -487,26 +490,34 @@
 
   @Test
   public void pushForMasterWithTopic() throws Exception {
-    String topic = "my/topic";
-    // specify topic as option
-    PushOneCommit.Result r = pushTo("refs/for/master%topic=" + topic);
-    r.assertOkStatus();
-    r.assertChange(Change.Status.NEW, topic);
+    TopicValidator topicValidator = new TopicValidator();
+    try (Registration registration = extensionRegistry.newRegistration().add(topicValidator)) {
+      String topic = "my/topic";
+      // specify topic as option
+      PushOneCommit.Result r = pushTo("refs/for/master%topic=" + topic);
+      r.assertOkStatus();
+      r.assertChange(Change.Status.NEW, topic);
+      assertThat(topicValidator.count()).isEqualTo(1);
+    }
   }
 
   @Test
   public void pushForMasterWithTopicOption() throws Exception {
-    String topicOption = "topic=myTopic";
-    List<String> pushOptions = new ArrayList<>();
-    pushOptions.add(topicOption);
+    TopicValidator topicValidator = new TopicValidator();
+    try (Registration registration = extensionRegistry.newRegistration().add(topicValidator)) {
+      String topicOption = "topic=myTopic";
+      List<String> pushOptions = new ArrayList<>();
+      pushOptions.add(topicOption);
 
-    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
-    push.setPushOptions(pushOptions);
-    PushOneCommit.Result r = push.to("refs/for/master");
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      push.setPushOptions(pushOptions);
+      PushOneCommit.Result r = push.to("refs/for/master");
 
-    r.assertOkStatus();
-    r.assertChange(Change.Status.NEW, "myTopic");
-    r.assertPushOptions(pushOptions);
+      r.assertOkStatus();
+      r.assertChange(Change.Status.NEW, "myTopic");
+      r.assertPushOptions(pushOptions);
+      assertThat(topicValidator.count()).isEqualTo(1);
+    }
   }
 
   @Test
@@ -1040,7 +1051,7 @@
     PushOneCommit.Result r = pushTo("refs/for/master%l=Code-Review");
     r.assertOkStatus();
     ChangeInfo ci = get(r.getChangeId(), DETAILED_LABELS, MESSAGES, DETAILED_ACCOUNTS);
-    LabelInfo cr = ci.labels.get("Code-Review");
+    LabelInfo cr = ci.labels.get(LabelId.CODE_REVIEW);
     assertThat(cr.all).hasSize(1);
     assertThat(cr.all.get(0).name).isEqualTo("Administrator");
     assertThat(cr.all.get(0).value).isEqualTo(1);
@@ -1058,7 +1069,7 @@
     r = push.to("refs/for/master%l=Code-Review+2");
 
     ci = get(r.getChangeId(), DETAILED_LABELS, MESSAGES, DETAILED_ACCOUNTS);
-    cr = ci.labels.get("Code-Review");
+    cr = ci.labels.get(LabelId.CODE_REVIEW);
     assertThat(Iterables.getLast(ci.messages).message)
         .isEqualTo("Uploaded patch set 2: Code-Review+2.");
     // Check that the user who pushed the change was added as a reviewer since they added a vote
@@ -1097,7 +1108,7 @@
     r = push.to("refs/for/master%l=Code-Review+2");
 
     ChangeInfo ci = get(r.getChangeId(), DETAILED_LABELS, MESSAGES, DETAILED_ACCOUNTS);
-    LabelInfo cr = ci.labels.get("Code-Review");
+    LabelInfo cr = ci.labels.get(LabelId.CODE_REVIEW);
     assertThat(Iterables.getLast(ci.messages).message)
         .isEqualTo("Uploaded patch set 2: Code-Review+2.");
 
@@ -1126,7 +1137,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);
@@ -1151,7 +1162,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);
@@ -1183,27 +1194,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
@@ -2384,6 +2388,19 @@
     }
   }
 
+  private static class TopicValidator implements TopicEditedListener {
+    private final AtomicInteger count = new AtomicInteger();
+
+    @Override
+    public void onTopicEdited(Event event) {
+      count.incrementAndGet();
+    }
+
+    public int count() {
+      return count.get();
+    }
+  }
+
   @Test
   public void skipValidation() throws Exception {
     String master = "refs/heads/master";
@@ -2728,6 +2745,22 @@
     assertPushOk(r, "refs/for/otherBranch");
   }
 
+  @Test
+  public void pushWithVoteDoesNotAddToAttentionSet() throws Exception {
+    String pushSpec = "refs/for/master%l=Code-Review+1";
+    PushOneCommit.Result r = pushTo(pushSpec);
+    r.assertOkStatus();
+    assertThat(r.getChange().attentionSet()).isEmpty();
+  }
+
+  @Test
+  public void pushForMasterWithUnknownOption() throws Exception {
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    push.setPushOptions(ImmutableList.of("unknown=foo"));
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertErrorStatus("\"--unknown\" is not a valid option");
+  }
+
   private DraftInput newDraft(String path, int line, String message) {
     DraftInput d = new DraftInput();
     d.path = path;
diff --git a/javatests/com/google/gerrit/acceptance/git/BUILD b/javatests/com/google/gerrit/acceptance/git/BUILD
index ef54c92..13311e3 100644
--- a/javatests/com/google/gerrit/acceptance/git/BUILD
+++ b/javatests/com/google/gerrit/acceptance/git/BUILD
@@ -21,7 +21,6 @@
     deps = [
         "//java/com/google/gerrit/acceptance:lib",
         "//java/com/google/gerrit/git",
-        "//java/com/google/gerrit/mail",
     ],
 )
 
diff --git a/javatests/com/google/gerrit/acceptance/git/HttpPushForReviewIT.java b/javatests/com/google/gerrit/acceptance/git/HttpPushForReviewIT.java
deleted file mode 100644
index 6b7adf1..0000000
--- a/javatests/com/google/gerrit/acceptance/git/HttpPushForReviewIT.java
+++ /dev/null
@@ -1,96 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.git;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.acceptance.FakeGroupAuditService;
-import com.google.gerrit.server.AnonymousUser;
-import com.google.gerrit.server.audit.HttpAuditEvent;
-import com.google.inject.Inject;
-import javax.servlet.http.HttpServletResponse;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.transport.CredentialsProvider;
-import org.eclipse.jgit.transport.RefSpec;
-import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;
-import org.junit.Before;
-import org.junit.Test;
-
-public class HttpPushForReviewIT extends AbstractPushForReview {
-  @Inject private FakeGroupAuditService auditService;
-
-  @Before
-  public void selectHttpUrl() throws Exception {
-    CredentialsProvider.setDefault(
-        new UsernamePasswordCredentialsProvider(admin.username(), admin.httpPassword()));
-    selectProtocol(Protocol.HTTP);
-    // Don't clear audit events here, since we can't guarantee all test setup has run yet.
-  }
-
-  @Test
-  public void receivePackAuditEventLog() throws Exception {
-    auditService.drainHttpAuditEvents();
-    testRepo
-        .git()
-        .push()
-        .setRemote("origin")
-        .setRefSpecs(new RefSpec("HEAD:refs/for/master"))
-        .call();
-
-    ImmutableList<HttpAuditEvent> auditEvents = auditService.drainHttpAuditEvents();
-    assertThat(auditEvents).hasSize(2);
-
-    HttpAuditEvent lsRemote = auditEvents.get(0);
-    assertThat(lsRemote.who.getAccountId()).isEqualTo(admin.id());
-    assertThat(lsRemote.what).endsWith("/info/refs?service=git-receive-pack");
-    assertThat(lsRemote.params).containsExactly("service", "git-receive-pack");
-    assertThat(lsRemote.httpStatus).isEqualTo(HttpServletResponse.SC_OK);
-
-    HttpAuditEvent receivePack = auditEvents.get(1);
-    assertThat(receivePack.who.getAccountId()).isEqualTo(admin.id());
-    assertThat(receivePack.what).endsWith("/git-receive-pack");
-    assertThat(receivePack.params).isEmpty();
-    assertThat(receivePack.httpStatus).isEqualTo(HttpServletResponse.SC_OK);
-  }
-
-  @Test
-  public void uploadPackAuditEventLog() throws Exception {
-    auditService.drainHttpAuditEvents();
-    // testRepo is already a clone. Make a server-side change so we have something to fetch.
-    try (Repository repo = repoManager.openRepository(project);
-        TestRepository<Repository> tr = new TestRepository<>(repo)) {
-      tr.branch("master").commit().create();
-    }
-    testRepo.git().fetch().call();
-
-    ImmutableList<HttpAuditEvent> auditEvents = auditService.drainHttpAuditEvents();
-    assertThat(auditEvents).hasSize(2);
-
-    HttpAuditEvent lsRemote = auditEvents.get(0);
-    // Repo URL doesn't include /a, so fetching doesn't cause authentication.
-    assertThat(lsRemote.who).isInstanceOf(AnonymousUser.class);
-    assertThat(lsRemote.what).endsWith("/info/refs?service=git-upload-pack");
-    assertThat(lsRemote.params).containsExactly("service", "git-upload-pack");
-    assertThat(lsRemote.httpStatus).isEqualTo(HttpServletResponse.SC_OK);
-
-    HttpAuditEvent uploadPack = auditEvents.get(1);
-    assertThat(lsRemote.who).isInstanceOf(AnonymousUser.class);
-    assertThat(uploadPack.what).endsWith("/git-upload-pack");
-    assertThat(uploadPack.params).isEmpty();
-    assertThat(uploadPack.httpStatus).isEqualTo(HttpServletResponse.SC_OK);
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
index 1a2ae7c..385780b 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
@@ -82,17 +82,14 @@
         .update();
   }
 
-  @SuppressWarnings("TruthIncompatibleType")
   @Test
   public void mixingMagicAndRegularPush() throws Exception {
     testRepo.branch("HEAD").commit().create();
     PushResult r = push("HEAD:refs/heads/master", "HEAD:refs/for/master");
 
     String msg = "cannot combine normal pushes and magic pushes";
-    assertThat(r.getRemoteUpdate("refs/heads/master"))
-        .isNotEqualTo(/* expected: RemoteRefUpdate, actual: Status */ Status.OK);
-    assertThat(r.getRemoteUpdate("refs/for/master"))
-        .isNotEqualTo(/* expected: RemoteRefUpdate, actual: Status */ Status.OK);
+    assertThat(r.getRemoteUpdate("refs/heads/master").getStatus()).isNotEqualTo(Status.OK);
+    assertThat(r.getRemoteUpdate("refs/for/master").getStatus()).isNotEqualTo(Status.OK);
     assertThat(r.getRemoteUpdate("refs/for/master").getMessage()).isEqualTo(msg);
   }
 
@@ -364,7 +361,7 @@
         .update();
 
     String project2 = name("project2");
-    gApi.projects().create(project2);
+    projectOperations.newProject().name(project2).create();
 
     ObjectId oldId = forceFetch("refs/meta/config");
 
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index 0930815..b7acbe2 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -48,6 +48,7 @@
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.receive.ReceiveCommitsAdvertiseRefsHookChain;
 import com.google.gerrit.server.git.receive.testing.TestRefAdvertiser;
@@ -118,7 +119,7 @@
   @Before
   public void setUp() throws Exception {
     admins = adminGroupUuid();
-    nonInteractiveUsers = groupUuid("Service Users");
+    nonInteractiveUsers = groupUuid(ServiceUserClassifier.SERVICE_USERS);
     setUpPermissions();
     setUpChanges();
   }
@@ -402,6 +403,39 @@
   }
 
   @Test
+  public void uploadPackSubsetOfBranchesVisibleAllPatchsets() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
+
+    PushOneCommit.Result pushResult =
+        pushFactory.create(admin.newIdent(), testRepo).to("refs/for/master");
+    pushResult.assertOkStatus();
+    String firstPatchSetRef = RefNames.patchSetRef(pushResult.getPatchSetId());
+
+    pushResult = amendChange(pushResult.getChangeId());
+    pushResult.assertOkStatus();
+
+    String secondPatchSetRef = RefNames.patchSetRef(pushResult.getPatchSetId());
+
+    assertUploadPackRefs(
+        "HEAD",
+        psRef1,
+        metaRef1,
+        psRef3,
+        metaRef3,
+        RefNames.changeMetaRef(pushResult.getChange().getId()),
+        firstPatchSetRef,
+        // include all patchsets of the visible changes
+        secondPatchSetRef,
+        "refs/heads/master",
+        "refs/tags/master-tag");
+    // tree-tag not visible. See comment in subsetOfBranchesVisibleIncludingHead.
+  }
+
+  @Test
   public void uploadPackSubsetOfBranchesAndEditsVisibleWithViewPrivateChanges() throws Exception {
     projectOperations
         .project(project)
diff --git a/javatests/com/google/gerrit/acceptance/pgm/BUILD b/javatests/com/google/gerrit/acceptance/pgm/BUILD
index 64bd25c..5b18d02 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/BUILD
+++ b/javatests/com/google/gerrit/acceptance/pgm/BUILD
@@ -27,7 +27,6 @@
     labels = [
         "docker",
         "elastic",
-        "exclusive",
         "pgm",
         "no_windows",
     ],
diff --git a/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java b/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java
index 23d7658..093711f 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.schema.NoteDbSchemaVersion;
 import com.google.gerrit.server.schema.Schema_184;
 import com.google.gerrit.testing.TestUpdateUI;
@@ -26,7 +27,8 @@
 import org.junit.Test;
 
 public class Schema_184IT extends AbstractDaemonTest {
-  private static final AccountGroup.NameKey SERVICE_USERS = AccountGroup.nameKey("Service Users");
+  private static final AccountGroup.NameKey SERVICE_USERS =
+      AccountGroup.nameKey(ServiceUserClassifier.SERVICE_USERS);
   private static final AccountGroup.NameKey NON_INTERACTIVE_USERS =
       AccountGroup.nameKey("Non-Interactive Users");
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
index f5d9e3a..02916c7 100644
--- a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -18,6 +18,13 @@
 import static org.apache.http.HttpStatus.SC_CREATED;
 import static org.apache.http.HttpStatus.SC_INTERNAL_SERVER_ERROR;
 import static org.apache.http.HttpStatus.SC_OK;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyZeroInteractions;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
@@ -36,7 +43,6 @@
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.httpd.restapi.ParameterParser;
 import com.google.gerrit.httpd.restapi.RestApiServlet;
-import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.validators.CommitValidationException;
@@ -52,11 +58,10 @@
 import com.google.gerrit.server.validators.ProjectCreationValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
-import java.util.ArrayList;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.Set;
 import org.apache.http.message.BasicHeader;
 import org.junit.Rule;
 import org.junit.Test;
@@ -337,7 +342,7 @@
     assertThat(LoggingContext.getInstance().getTags().isEmpty()).isTrue();
     assertForceLogging(false);
     try (TraceContext traceContext = TraceContext.open().forceLogging().addTag("foo", "bar")) {
-      SortedMap<String, SortedSet<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
+      Map<String, ? extends Set<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
       assertThat(tagMap.keySet()).containsExactly("foo");
       assertThat(tagMap.get("foo")).containsExactly("bar");
       assertForceLogging(true);
@@ -348,7 +353,7 @@
               () -> {
                 // Verify that the tags and force logging flag have been propagated to the new
                 // thread.
-                SortedMap<String, SortedSet<Object>> threadTagMap =
+                Map<String, ? extends Set<Object>> threadTagMap =
                     LoggingContext.getInstance().getTags().asMap();
                 expect.that(threadTagMap.keySet()).containsExactly("foo");
                 expect.that(threadTagMap.get("foo")).containsExactly("bar");
@@ -370,37 +375,31 @@
 
   @Test
   public void performanceLoggingForRestCall() throws Exception {
-    TestPerformanceLogger testPerformanceLogger = new TestPerformanceLogger();
+    PerformanceLogger testPerformanceLogger = mock(PerformanceLogger.class);
     try (Registration registration =
         extensionRegistry.newRegistration().add(testPerformanceLogger)) {
       RestResponse response = adminRestSession.put("/projects/new10");
       assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
-
-      // This assertion assumes that the server invokes the PerformanceLogger plugins before it
-      // sends
-      // the response to the client. If this assertion gets flaky it's likely that this got changed
-      // on
-      // server-side.
-      assertThat(testPerformanceLogger.logEntries()).isNotEmpty();
+      verify(testPerformanceLogger, timeout(5000).atLeastOnce()).log(anyString(), anyLong(), any());
     }
   }
 
   @Test
   public void performanceLoggingForPush() throws Exception {
-    TestPerformanceLogger testPerformanceLogger = new TestPerformanceLogger();
+    PerformanceLogger testPerformanceLogger = mock(PerformanceLogger.class);
     try (Registration registration =
         extensionRegistry.newRegistration().add(testPerformanceLogger)) {
       PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
       PushOneCommit.Result r = push.to("refs/heads/master");
       r.assertOkStatus();
-      assertThat(testPerformanceLogger.logEntries()).isNotEmpty();
+      verify(testPerformanceLogger, timeout(5000).atLeastOnce()).log(anyString(), anyLong(), any());
     }
   }
 
   @Test
   @GerritConfig(name = "tracing.performanceLogging", value = "false")
   public void noPerformanceLoggingIfDisabled() throws Exception {
-    TestPerformanceLogger testPerformanceLogger = new TestPerformanceLogger();
+    PerformanceLogger testPerformanceLogger = mock(PerformanceLogger.class);
     try (Registration registration =
         extensionRegistry.newRegistration().add(testPerformanceLogger)) {
       RestResponse response = adminRestSession.put("/projects/new11");
@@ -410,7 +409,7 @@
       PushOneCommit.Result r = push.to("refs/heads/master");
       r.assertOkStatus();
 
-      assertThat(testPerformanceLogger.logEntries()).isEmpty();
+      verifyZeroInteractions(testPerformanceLogger);
     }
   }
 
@@ -713,7 +712,7 @@
 
   @Test
   @GerritConfig(name = "retry.retryWithTraceOnFailure", value = "true")
-  public void autoRetryWithTrace() throws Exception {
+  public void noAutoRetryIfExceptionCausesNormalRetrying() throws Exception {
     String changeId = createChange().getChangeId();
     approve(changeId);
 
@@ -723,49 +722,6 @@
       RestResponse response = adminRestSession.post("/changes/" + changeId + "/submit");
       assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
       assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).startsWith("retry-on-failure-");
-      assertThat(traceSubmitRule.traceId).startsWith("retry-on-failure-");
-      assertThat(traceSubmitRule.isLoggingForced).isTrue();
-    }
-  }
-
-  @Test
-  @GerritConfig(name = "retry.retryWithTraceOnFailure", value = "true")
-  public void noAutoRetryIfExceptionCausesNormalRetrying() throws Exception {
-    String changeId = createChange().getChangeId();
-    approve(changeId);
-
-    TraceSubmitRule traceSubmitRule = new TraceSubmitRule();
-    traceSubmitRule.failAlways = true;
-    try (Registration registration =
-        extensionRegistry
-            .newRegistration()
-            .add(traceSubmitRule)
-            .add(
-                new ExceptionHook() {
-                  @Override
-                  public boolean shouldRetry(String actionType, String actionName, Throwable t) {
-                    return true;
-                  }
-                })) {
-      RestResponse response = adminRestSession.post("/changes/" + changeId + "/submit");
-      assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
-      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
-      assertThat(traceSubmitRule.traceId).isNull();
-      assertThat(traceSubmitRule.isLoggingForced).isFalse();
-    }
-  }
-
-  @Test
-  public void noAutoRetryWithTraceIfDisabled() throws Exception {
-    String changeId = createChange().getChangeId();
-    approve(changeId);
-
-    TraceSubmitRule traceSubmitRule = new TraceSubmitRule();
-    traceSubmitRule.failOnce = true;
-    try (Registration registration = extensionRegistry.newRegistration().add(traceSubmitRule)) {
-      RestResponse response = adminRestSession.post("/changes/" + changeId + "/submit");
-      assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
-      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
       assertThat(traceSubmitRule.traceId).isNull();
       assertThat(traceSubmitRule.isLoggingForced).isFalse();
     }
@@ -844,19 +800,6 @@
     }
   }
 
-  private static class TestPerformanceLogger implements PerformanceLogger {
-    private List<PerformanceLogEntry> logEntries = new ArrayList<>();
-
-    @Override
-    public void log(String operation, long durationMs, Metadata metadata) {
-      logEntries.add(PerformanceLogEntry.create(operation, metadata));
-    }
-
-    ImmutableList<PerformanceLogEntry> logEntries() {
-      return ImmutableList.copyOf(logEntries);
-    }
-  }
-
   @AutoValue
   abstract static class PerformanceLogEntry {
     static PerformanceLogEntry create(String operation, Metadata metadata) {
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
index a5cf3e1..b999abd 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.extensions.common.AccountDetailInfo;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.inject.Inject;
 import org.junit.Test;
 
@@ -45,7 +46,11 @@
   public void getDetailForServiceUser() throws Exception {
     Account.Id serviceUser = accountOperations.newAccount().create();
     groupOperations
-        .group(groupCache.get(AccountGroup.nameKey("Service Users")).get().getGroupUUID())
+        .group(
+            groupCache
+                .get(AccountGroup.nameKey(ServiceUserClassifier.SERVICE_USERS))
+                .get()
+                .getGroupUUID())
         .forUpdate()
         .addMember(serviceUser)
         .update();
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java
index 2e2f5d9..1e61d0a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.inject.Inject;
 import org.junit.Test;
 
@@ -70,7 +71,8 @@
   @Test
   public void getServiceUserAccount() throws Exception {
     TestAccount serviceUser =
-        accountCreator.create("robot1", "robot1@example.com", "Ro Bot", "Ro", "Service Users");
+        accountCreator.create(
+            "robot1", "robot1@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
     assertThat(serviceUser.tags()).containsExactly("SERVICE_USER");
     testGetAccount(serviceUser.id().toString(), serviceUser);
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index 5c596dc..bf8de93 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSetApproval;
@@ -183,7 +184,7 @@
 
     ReviewInput in = new ReviewInput();
     in.onBehalfOf = user.id().toString();
-    in.label("Verified", 1);
+    in.label(LabelId.VERIFIED, 1);
 
     AuthException thrown = assertThrows(AuthException.class, () -> revision.review(in));
     assertThat(thrown)
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
index 2c9107c..b70cab8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
@@ -249,4 +249,30 @@
   public void postWithoutBody() throws Exception {
     adminRestSession.post("/accounts/" + admin.username() + "/watched.projects").assertOK();
   }
+
+  @Test
+  public void nullProjectThrowsBadRequestException() {
+    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.project = null;
+    projectsToWatch.add(pwi);
+    Throwable t =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.accounts().self().setWatchedProjects(projectsToWatch));
+    assertThat(t.getMessage()).isEqualTo("project name must be specified");
+  }
+
+  @Test
+  public void emptyProjectThrowsBadRequestException() {
+    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.project = "  ";
+    projectsToWatch.add(pwi);
+    Throwable t =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.accounts().self().setWatchedProjects(projectsToWatch));
+    assertThat(t.getMessage()).isEqualTo("project name must be specified");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/auth/AuthenticationCheckIT.java b/javatests/com/google/gerrit/acceptance/rest/auth/AuthenticationCheckIT.java
index 9298b43..00b1c55 100644
--- a/javatests/com/google/gerrit/acceptance/rest/auth/AuthenticationCheckIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/auth/AuthenticationCheckIT.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.acceptance.rest.auth;
 
-import static com.google.common.truth.Truth.assertThat;
-
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.RestSession;
@@ -34,6 +32,5 @@
     RestSession anonymous = new RestSession(server, null);
     RestResponse r = anonymous.get("/auth-check");
     r.assertForbidden();
-    assertThat(r.getHeader("Content-Length")).isEqualTo("0");
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedRootRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedRootRestApiBindingsIT.java
index b447534..16dc294 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedRootRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedRootRestApiBindingsIT.java
@@ -16,6 +16,7 @@
 
 import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
 
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -48,6 +49,7 @@
 import javax.servlet.http.HttpServletRequestWrapper;
 import javax.servlet.http.HttpServletResponse;
 import org.junit.Test;
+import org.kohsuke.args4j.Option;
 
 /**
  * Tests for checking plugin-provided REST API bindings directly under {@code /}.
@@ -192,8 +194,15 @@
 
   @Singleton
   static class TestGet implements RestReadView<TestPluginResource> {
+
+    @Option(name = "--crash")
+    String crash;
+
     @Override
     public Response<String> apply(TestPluginResource resource) throws Exception {
+      if (!Strings.nullToEmpty(crash).isEmpty()) {
+        throw new IllegalStateException();
+      }
       return Response.ok("test");
     }
   }
@@ -204,4 +213,13 @@
       RestApiCallHelper.execute(adminRestSession, TEST_CALLS.asList());
     }
   }
+
+  @Test
+  public void testOptionOnSingletonIsIgnored() throws Exception {
+    try (AutoCloseable ignored = installPlugin(PLUGIN_NAME, null, MyPluginHttpModule.class, null)) {
+      RestApiCallHelper.execute(
+          adminRestSession,
+          RestCall.get("/plugins/" + PLUGIN_NAME + "/test-collection/1/detail?crash=xyz"));
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index faef5aa..796ce38 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -59,6 +59,7 @@
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Permission;
@@ -228,7 +229,9 @@
                   "Cannot rebase "
                       + change2hash
                       + ": The change could "
-                      + "not be rebased due to a conflict during merge.");
+                      + "not be rebased due to a conflict during merge.\n\n"
+                      + "merge conflict(s):\n"
+                      + "a.txt");
           break;
         case MERGE_ALWAYS:
         case MERGE_IF_NECESSARY:
@@ -413,7 +416,7 @@
         .forUpdate()
         .add(block(Permission.SUBMIT).ref("refs/*").group(CHANGE_OWNER))
         .add(allow(Permission.SUBMIT).ref("refs/heads/*").group(REGISTERED_USERS))
-        .add(allowLabel("Code-Review").ref("refs/*").group(REGISTERED_USERS).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/*").group(REGISTERED_USERS).range(-2, +2))
         .update();
 
     TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
@@ -439,7 +442,7 @@
         .forUpdate()
         .add(block(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
         .add(allow(Permission.SUBMIT).ref("refs/*").group(CHANGE_OWNER))
-        .add(allowLabel("Code-Review").ref("refs/*").group(REGISTERED_USERS).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/*").group(REGISTERED_USERS).range(-2, +2))
         .update();
 
     TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
@@ -1375,7 +1378,6 @@
     ChangeInfo info = get(change.getChangeId(), ListChangesOption.MESSAGES);
     assertThat(info.messages).isNotNull();
     Iterable<String> messages = Iterables.transform(info.messages, i -> i.message);
-    assertThat(messages).hasSize(3);
     String last = Iterables.getLast(messages);
     if (getSubmitType() == SubmitType.CHERRY_PICK) {
       assertThat(last).startsWith("Change has been successfully cherry-picked as");
@@ -1386,6 +1388,17 @@
     }
   }
 
+  @Test
+  public void submitSetsMergedOn() throws Throwable {
+    PushOneCommit.Result r = createChange();
+    assertThat(r.getChange().getMergedOn()).isEmpty();
+    submit(r.getChangeId());
+    assertThat(r.getChange().getMergedOn()).isPresent();
+    ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
+    assertThat(r.getChange().getMergedOn().get()).isEqualTo(change.updated);
+    assertThat(r.getChange().getMergedOn().get()).isEqualTo(change.submitted);
+  }
+
   @Override
   protected void updateProjectInput(ProjectInput in) {
     in.submitType = getSubmitType();
@@ -1439,6 +1452,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);
   }
@@ -1470,7 +1489,7 @@
 
   protected void assertApproved(String changeId, TestAccount user) throws Throwable {
     ChangeInfo c = get(changeId, DETAILED_LABELS);
-    LabelInfo cr = c.labels.get("Code-Review");
+    LabelInfo cr = c.labels.get(LabelId.CODE_REVIEW);
     assertThat(cr.all).hasSize(1);
     assertThat(cr.all.get(0).value).isEqualTo(2);
     assertThat(Account.id(cr.all.get(0)._accountId)).isEqualTo(user.id());
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..81c098f 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;
@@ -235,7 +236,9 @@
         change2.getChangeId(),
         "Cannot rebase "
             + change2.getCommit().name()
-            + ": The change could not be rebased due to a conflict during merge.");
+            + ": The change could not be rebased due to a conflict during merge.\n\n"
+            + "merge conflict(s):\n"
+            + "a.txt");
     RevCommit head = projectOperations.project(project).getHead("master");
     assertThat(head).isEqualTo(headAfterFirstSubmit);
     assertCurrentRevision(change2.getChangeId(), 1, change2.getCommit());
@@ -362,7 +365,9 @@
         "Cannot rebase "
             + change2.getCommit().getName()
             + ": "
-            + "The change could not be rebased due to a conflict during merge.");
+            + "The change could not be rebased due to a conflict during merge.\n\n"
+            + "merge conflict(s):\n"
+            + "fileName 2");
     assertThat(projectOperations.project(project).getHead("master")).isEqualTo(headAfterChange1);
   }
 
@@ -384,4 +389,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/ActionsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
index c6a2819..e35f758 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
@@ -20,26 +20,20 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.truth.MapSubject.assertThatMap;
 
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.TestProjectInput;
-import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.ActionVisitor;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.server.change.RevisionJson;
-import com.google.gerrit.server.change.testing.TestChangeETagComputation;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
@@ -56,7 +50,6 @@
     return submitWholeTopicEnabledConfig();
   }
 
-  @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private RevisionJson.Factory revisionJsonFactory;
   @Inject private ExtensionRegistry extensionRegistry;
 
@@ -68,10 +61,6 @@
     return gApi.changes().id(id).get().actions;
   }
 
-  protected String getETag(String id) throws Exception {
-    return gApi.changes().id(id).current().etag();
-  }
-
   @Test
   public void changeActionOneMergedChangeHasOnlyNormalRevert() throws Exception {
     String changeId = createChangeWithTopic().getChangeId();
@@ -138,124 +127,6 @@
   }
 
   @Test
-  public void revisionActionsETag() throws Exception {
-    String parent = createChange().getChangeId();
-    String change = createChangeWithTopic().getChangeId();
-    approve(change);
-    String etag1 = getETag(change);
-
-    approve(parent);
-    String etag2 = getETag(change);
-
-    String changeWithSameTopic = createChangeWithTopic().getChangeId();
-    String etag3 = getETag(change);
-
-    approve(changeWithSameTopic);
-    String etag4 = getETag(change);
-
-    if (isSubmitWholeTopicEnabled()) {
-      assertThat(ImmutableList.of(etag1, etag2, etag3, etag4)).containsNoDuplicates();
-    } else {
-      assertThat(etag2).isNotEqualTo(etag1);
-      assertThat(etag3).isEqualTo(etag2);
-      assertThat(etag4).isEqualTo(etag2);
-    }
-  }
-
-  @Test
-  public void revisionActionsAnonymousETag() throws Exception {
-    String parent = createChange().getChangeId();
-    String change = createChangeWithTopic().getChangeId();
-    approve(change);
-
-    requestScopeOperations.setApiUserAnonymous();
-    String etag1 = getETag(change);
-
-    requestScopeOperations.setApiUser(admin.id());
-    approve(parent);
-
-    requestScopeOperations.setApiUserAnonymous();
-    String etag2 = getETag(change);
-
-    requestScopeOperations.setApiUser(admin.id());
-    String changeWithSameTopic = createChangeWithTopic().getChangeId();
-
-    requestScopeOperations.setApiUserAnonymous();
-    String etag3 = getETag(change);
-
-    requestScopeOperations.setApiUser(admin.id());
-    approve(changeWithSameTopic);
-
-    requestScopeOperations.setApiUserAnonymous();
-    String etag4 = getETag(change);
-
-    if (isSubmitWholeTopicEnabled()) {
-      assertThat(ImmutableList.of(etag1, etag2, etag3, etag4)).containsNoDuplicates();
-    } else {
-      assertThat(etag2).isNotEqualTo(etag1);
-      assertThat(etag3).isEqualTo(etag2);
-      assertThat(etag4).isEqualTo(etag2);
-    }
-  }
-
-  @Test
-  @TestProjectInput(submitType = SubmitType.CHERRY_PICK)
-  public void revisionActionsAnonymousETagCherryPickStrategy() throws Exception {
-    String parent = createChange().getChangeId();
-    String change = createChange().getChangeId();
-    approve(change);
-
-    requestScopeOperations.setApiUserAnonymous();
-    String etag1 = getETag(change);
-
-    requestScopeOperations.setApiUser(admin.id());
-    approve(parent);
-
-    requestScopeOperations.setApiUserAnonymous();
-    String etag2 = getETag(change);
-    assertThat(etag2).isEqualTo(etag1);
-  }
-
-  @Test
-  public void pluginCanContributeToETagComputation() throws Exception {
-    String change = createChange().getChangeId();
-    String oldETag = getETag(change);
-
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(TestChangeETagComputation.withETag("foo"))) {
-      assertThat(getETag(change)).isNotEqualTo(oldETag);
-    }
-
-    assertThat(getETag(change)).isEqualTo(oldETag);
-  }
-
-  @Test
-  public void returningNullFromETagComputationDoesNotBreakGerrit() throws Exception {
-    String change = createChange().getChangeId();
-    String oldETag = getETag(change);
-
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(TestChangeETagComputation.withETag(null))) {
-      assertThat(getETag(change)).isEqualTo(oldETag);
-    }
-  }
-
-  @Test
-  public void throwingExceptionFromETagComputationDoesNotBreakGerrit() throws Exception {
-    String change = createChange().getChangeId();
-    String oldETag = getETag(change);
-
-    try (Registration registration =
-        extensionRegistry
-            .newRegistration()
-            .add(
-                TestChangeETagComputation.withException(
-                    new StorageException("exception during test")))) {
-      assertThat(getETag(change)).isEqualTo(oldETag);
-    }
-  }
-
-  @Test
   public void revisionActionsTwoChangesInTopic_conflicting() throws Exception {
     String changeId = createChangeWithTopic().getChangeId();
     approve(changeId);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index 61eef63..7e6a822 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;
@@ -47,6 +48,7 @@
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -61,6 +63,7 @@
 import java.util.List;
 import java.util.concurrent.TimeUnit;
 import java.util.function.LongSupplier;
+import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 import org.junit.Before;
 import org.junit.Test;
@@ -113,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);
   }
@@ -162,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));
@@ -189,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,
@@ -197,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(
@@ -212,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();
@@ -242,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();
 
@@ -256,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();
 
@@ -284,21 +286,26 @@
 
   @Test
   public void robotSubmitsRemovesUsers() throws Exception {
-    PushOneCommit.Result r1 = createChange("refs/heads/master", "file1", "content");
+    PushOneCommit.Result r = createChange("refs/heads/master", "file1", "content");
 
-    change(r1)
-        .current()
-        .review(ReviewInput.approve().addUserToAttentionSet(user.email(), "reason"));
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
 
     TestAccount robot =
         accountCreator.create(
-            "robot2", "robot2@example.com", "Ro Bot", "Ro", "Service Users", "Administrators");
+            "robot2",
+            "robot2@example.com",
+            "Ro Bot",
+            "Ro",
+            ServiceUserClassifier.SERVICE_USERS,
+            "Administrators");
     requestScopeOperations.setApiUser(robot.id());
-    change(r1).current().submit();
+    change(r).current().review(ReviewInput.approve());
+    change(r).current().submit();
 
     // Attention set updates that relate to the admin (the person who replied) are filtered out.
     AttentionSetUpdate attentionSet =
-        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r1, user));
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
 
     assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
     assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
@@ -460,7 +467,12 @@
 
     TestAccount robot =
         accountCreator.create(
-            "robot1", "robot1@example.com", "Ro Bot", "Ro", "Service Users", "Administrators");
+            "robot1",
+            "robot1@example.com",
+            "Ro Bot",
+            "Ro",
+            ServiceUserClassifier.SERVICE_USERS,
+            "Administrators");
     requestScopeOperations.setApiUser(robot.id());
     change(r).setReadyForReview();
     AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
@@ -483,6 +495,24 @@
   }
 
   @Test
+  public void rebaseDoesNotAddToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).setWorkInProgress();
+    change(r).addReviewer(user.email());
+
+    // create an unrelated change so that we can rebase
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result unrelated = createChange();
+    gApi.changes().id(unrelated.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(unrelated.getChangeId()).current().submit();
+
+    gApi.changes().id(r.getChangeId()).rebase();
+
+    // rebase has no impact on the attention set
+    assertThat(r.getChange().attentionSet()).isEmpty();
+  }
+
+  @Test
   public void readyForReviewWhileRemovingReviewerRemovesThemToAttentionSet() throws Exception {
     PushOneCommit.Result r = createChange();
     change(r).setWorkInProgress();
@@ -521,7 +551,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();
@@ -555,7 +587,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 =
@@ -575,7 +608,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(), "");
 
@@ -599,7 +633,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()
@@ -760,7 +795,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);
 
@@ -1132,6 +1175,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());
@@ -1222,8 +1268,11 @@
   @Test
   public void robotsNotAddedToAttentionSet() throws Exception {
     TestAccount robot =
-        accountCreator.create("robot1", "robot1@example.com", "Ro Bot", "Ro", "Service Users");
+        accountCreator.create(
+            "robot1", "robot1@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
     PushOneCommit.Result r = createChange();
+    // Make the robot active on the change.
+    change(r).addReviewer(robot.email());
 
     // Throw an error when adding a robot explicitly.
     BadRequestException exception =
@@ -1242,7 +1291,8 @@
   @Test
   public void robotAddingAReviewerChangeAttentionSet() throws Exception {
     TestAccount robot =
-        accountCreator.create("robot2", "robot2@example.com", "Ro Bot", "Ro", "Service Users");
+        accountCreator.create(
+            "robot2", "robot2@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
     PushOneCommit.Result r = createChange();
     requestScopeOperations.setApiUser(robot.id());
     change(r).addReviewer(user.id().toString());
@@ -1258,7 +1308,8 @@
   @Test
   public void robotReviewDoesNotChangeAttentionSet() throws Exception {
     TestAccount robot =
-        accountCreator.create("robot2", "robot2@example.com", "Ro Bot", "Ro", "Service Users");
+        accountCreator.create(
+            "robot2", "robot2@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
     PushOneCommit.Result r = createChange();
     requestScopeOperations.setApiUser(robot.id());
     change(r).current().review(ReviewInput.recommend());
@@ -1269,7 +1320,8 @@
   @Test
   public void robotReviewWithNegativeLabelAddsOwner() throws Exception {
     TestAccount robot =
-        accountCreator.create("robot2", "robot2@example.com", "Ro Bot", "Ro", "Service Users");
+        accountCreator.create(
+            "robot2", "robot2@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
     PushOneCommit.Result r = createChange();
     requestScopeOperations.setApiUser(robot.id());
     change(r).current().review(ReviewInput.dislike());
@@ -1284,7 +1336,8 @@
   @Test
   public void robotCommentDoesNotAddOwnerOnClosedChanges() throws Exception {
     TestAccount robot =
-        accountCreator.create("robot2", "robot2@example.com", "Ro Bot", "Ro", "Service Users");
+        accountCreator.create(
+            "robot2", "robot2@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).abandon();
 
@@ -1301,7 +1354,8 @@
   @Test
   public void robotCanChangeAttentionSetExplicitly() throws Exception {
     TestAccount robot =
-        accountCreator.create("robot2", "robot2@example.com", "Ro Bot", "Ro", "Service Users");
+        accountCreator.create(
+            "robot2", "robot2@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
     PushOneCommit.Result r = createChange();
     requestScopeOperations.setApiUser(robot.id());
     change(r).current().review(new ReviewInput().addUserToAttentionSet(admin.email(), "reason"));
@@ -1317,13 +1371,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
@@ -1368,43 +1424,30 @@
   public void attentionSetEmailHeader() throws Exception {
     PushOneCommit.Result r = createChange();
     TestAccount user2 = accountCreator.user2();
+
+    // The pattern ensures the header mentions the attention set requirements in any order.
+    Pattern attentionSetHeaderPattern =
+        Pattern.compile(
+            String.format(
+                "Attention is currently required from: (%s|%s), (%s|%s).",
+                user2.fullName(), user.fullName(), user.fullName(), user2.fullName()));
     // Add user and user2 to the attention set.
     change(r)
         .current()
         .review(
             ReviewInput.create().reviewer(user.email()).reviewer(accountCreator.user2().email()));
     assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
-        .contains(
-            "Attention is currently required from: "
-                + user2.fullName()
-                + ", "
-                + user.fullName()
-                + ".");
+        .containsMatch(attentionSetHeaderPattern);
     assertThat(Iterables.getOnlyElement(sender.getMessages()).htmlBody())
-        .contains(
-            "Attention is currently required from: "
-                + user2.fullName()
-                + ", "
-                + user.fullName()
-                + ".");
+        .containsMatch(attentionSetHeaderPattern);
     sender.clear();
 
     // Irrelevant reply, User and User2 are still in the attention set.
     change(r).current().review(ReviewInput.approve());
     assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
-        .contains(
-            "Attention is currently required from: "
-                + user2.fullName()
-                + ", "
-                + user.fullName()
-                + ".");
+        .containsMatch(attentionSetHeaderPattern);
     assertThat(Iterables.getOnlyElement(sender.getMessages()).htmlBody())
-        .contains(
-            "Attention is currently required from: "
-                + user2.fullName()
-                + ", "
-                + user.fullName()
-                + ".");
+        .containsMatch(attentionSetHeaderPattern);
     sender.clear();
 
     // Abandon the change which removes user from attention set; there is an email but without the
@@ -1461,6 +1504,58 @@
   }
 
   @Test
+  public void attentionSetWithEmailFilterFiltersNewPatchsets() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add preference for the user such that they only receive an email on changes that require
+    // their attention.
+    requestScopeOperations.setApiUser(user.id());
+    GeneralPreferencesInfo prefs = gApi.accounts().self().getPreferences();
+    prefs.emailStrategy = EmailStrategy.ATTENTION_SET_ONLY;
+    gApi.accounts().self().setPreferences(prefs);
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Add user to reviewers but not to the attention set
+    change(r)
+        .current()
+        .review(
+            ReviewInput.create()
+                .reviewer(user.email())
+                .removeUserFromAttentionSet(user.email(), "reason"));
+    sender.clear();
+
+    // amending a change doesn't send an email when user is not in the attention set.
+    amendChange(r.getChangeId());
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void attentionSetWithEmailFilterStillReceivesSubmitEmail() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add preference for the user such that they only receive an email on changes that require
+    // their attention.
+    requestScopeOperations.setApiUser(user.id());
+    GeneralPreferencesInfo prefs = gApi.accounts().self().getPreferences();
+    prefs.emailStrategy = EmailStrategy.ATTENTION_SET_ONLY;
+    gApi.accounts().self().setPreferences(prefs);
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Add user to reviewers but not to the attention set
+    change(r)
+        .current()
+        .review(
+            ReviewInput.approve()
+                .reviewer(user.email())
+                .removeUserFromAttentionSet(user.email(), "reason"));
+    sender.clear();
+
+    // submitting the change sends an email even when user is not in the attention set.
+    change(r).current().submit();
+    assertThat(sender.getMessages()).isNotEmpty();
+  }
+
+  @Test
   public void attentionSetWithEmailFilterImpactingOnlyChangeEmails() throws Exception {
     // Add preference for the user such that they only receive an email on changes that require
     // their attention.
@@ -1475,6 +1570,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/ChangeMessagesIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
index 264ced6..ad3a3c1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.extensions.api.changes.DeleteChangeMessageInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -253,7 +254,7 @@
     c.message = "comment 1";
     c.path = FILE_NAME;
 
-    ReviewInput reviewInput = new ReviewInput().label("Code-Review", 1);
+    ReviewInput reviewInput = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
     reviewInput.comments = ImmutableMap.of(c.path, Lists.newArrayList(c));
     reviewInput.message = changeMessage;
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeMetaIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMetaIT.java
new file mode 100644
index 0000000..e025c52
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMetaIT.java
@@ -0,0 +1,150 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.entities.RefNames.changeMetaRef;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gson.reflect.TypeToken;
+import com.google.gson.stream.JsonReader;
+import com.google.inject.AbstractModule;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+/** Test handling of the NoteDb commit hash in the GetChange endpoint */
+public class ChangeMetaIT extends AbstractDaemonTest {
+  @Test
+  public void metaSha1_fromIndex() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+
+    try (AutoCloseable ignored = disableNoteDb()) {
+      ChangeInfo change =
+          Iterables.getOnlyElement(gApi.changes().query().withQuery("change:" + changeId).get());
+
+      try (Repository repo = repoManager.openRepository(project)) {
+        assertThat(change.metaRevId)
+            .isEqualTo(
+                repo.exactRef(changeMetaRef(Change.id(change._number))).getObjectId().getName());
+      }
+    }
+  }
+
+  @Test
+  public void metaSha1_fromNoteDb() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    ChangeInfo before = gApi.changes().id(changeId).get();
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(before.metaRevId)
+          .isEqualTo(
+              repo.exactRef(changeMetaRef(Change.id(before._number))).getObjectId().getName());
+    }
+  }
+
+  @Test
+  public void ChangeInfo_metaSha1_parameter() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    gApi.changes().id(changeId).setMessage("before\n\n" + "Change-Id: " + result.getChangeId());
+    ChangeInfo before = gApi.changes().id(changeId).get();
+    gApi.changes().id(changeId).setMessage("after\n\n" + "Change-Id: " + result.getChangeId());
+    ChangeInfo after = gApi.changes().id(changeId).get();
+    assertThat(after.metaRevId).isNotEqualTo(before.metaRevId);
+
+    RestResponse resp = adminRestSession.get("/changes/" + changeId + "/?meta=" + before.metaRevId);
+    resp.assertOK();
+
+    ChangeInfo got;
+    try (JsonReader jsonReader = new JsonReader(resp.getReader())) {
+      jsonReader.setLenient(true);
+      got = newGson().fromJson(jsonReader, ChangeInfo.class);
+    }
+    assertThat(got.subject).isEqualTo(before.subject);
+  }
+
+  @Test
+  public void metaUnreachableSha1() throws Exception {
+    PushOneCommit.Result ch1 = createChange();
+    PushOneCommit.Result ch2 = createChange();
+
+    ChangeInfo info2 = gApi.changes().id(ch2.getChangeId()).get();
+
+    RestResponse resp =
+        adminRestSession.get("/changes/" + ch1.getChangeId() + "/?meta=" + info2.metaRevId);
+
+    resp.assertStatus(412);
+  }
+
+  protected static class PluginDefinedSimpleAttributeModule extends AbstractModule {
+    static class MyMetaHash extends PluginDefinedInfo {
+      String myMetaRef;
+    };
+
+    static PluginDefinedInfo newMyMetaHash(ChangeData cd) {
+      MyMetaHash mmh = new MyMetaHash();
+      mmh.myMetaRef = cd.notes().getMetaId().name();
+      return mmh;
+    }
+
+    @Override
+    public void configure() {
+      DynamicSet.bind(binder(), ChangePluginDefinedInfoFactory.class)
+          .toInstance(
+              (cds, bp, p) -> {
+                Map<Change.Id, PluginDefinedInfo> out = new HashMap<>();
+                cds.forEach(cd -> out.put(cd.getId(), newMyMetaHash(cd)));
+                return out;
+              });
+    }
+  }
+
+  @Test
+  public void pluginDefinedAttribute() throws Exception {
+    try (AutoCloseable ignored =
+        installPlugin("my-plugin", PluginDefinedSimpleAttributeModule.class)) {
+      PushOneCommit.Result result = createChange();
+      String changeId = result.getChangeId();
+      gApi.changes().id(changeId).setMessage("before\n\n" + "Change-Id: " + result.getChangeId());
+      ChangeInfo before = gApi.changes().id(changeId).get();
+      gApi.changes().id(changeId).setMessage("after\n\n" + "Change-Id: " + result.getChangeId());
+      ChangeInfo after = gApi.changes().id(changeId).get();
+
+      RestResponse resp =
+          adminRestSession.get("/changes/" + changeId + "/?meta=" + before.metaRevId);
+      resp.assertOK();
+
+      Map<String, Object> changeInfo =
+          newGson().fromJson(resp.getReader(), new TypeToken<Map<String, Object>>() {}.getType());
+      List<Object> plugins = (List<Object>) changeInfo.get("plugins");
+      Map<String, Object> myplugin = (Map<String, Object>) plugins.get(0);
+
+      assertThat(myplugin.get("my_meta_ref")).isEqualTo(before.metaRevId);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
index 10194eb..6bffdf7 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -146,8 +147,8 @@
     projectOperations
         .project(project)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(groupUUID).range(-2, 2))
-        .setExclusiveGroup(labelPermissionKey("Code-Review").ref("refs/heads/*"), exclusive)
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(groupUUID).range(-2, 2))
+        .setExclusiveGroup(labelPermissionKey(LabelId.CODE_REVIEW).ref("refs/heads/*"), exclusive)
         .update();
   }
 
@@ -156,7 +157,7 @@
         .project(project)
         .forUpdate()
         .add(
-            blockLabel("Code-Review")
+            blockLabel(LabelId.CODE_REVIEW)
                 .ref("refs/heads/*")
                 .group(SystemGroupBackend.CHANGE_OWNER)
                 .range(-2, 2))
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index a6bd5eb..f9493c2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -28,6 +28,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
@@ -37,6 +38,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
@@ -283,7 +285,7 @@
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
     assertReviewers(c, REVIEWER, user);
     assertReviewers(c, CC);
-    LabelInfo label = c.labels.get("Code-Review");
+    LabelInfo label = c.labels.get(LabelId.CODE_REVIEW);
     assertThat(label).isNotNull();
     assertThat(label.all).isNotNull();
     assertThat(label.all).hasSize(1);
@@ -313,7 +315,7 @@
     assertReviewers(c, CC, user);
     // Verify no approvals were added.
     assertThat(c.labels).isNotNull();
-    LabelInfo label = c.labels.get("Code-Review");
+    LabelInfo label = c.labels.get(LabelId.CODE_REVIEW);
     assertThat(label).isNotNull();
     assertThat(label.all).isNull();
   }
@@ -327,7 +329,7 @@
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
     assertReviewers(c, REVIEWER);
     assertReviewers(c, CC);
-    LabelInfo label = c.labels.get("Code-Review");
+    LabelInfo label = c.labels.get(LabelId.CODE_REVIEW);
     assertThat(label).isNotNull();
     assertThat(label.all).isNull();
 
@@ -342,7 +344,7 @@
     c = gApi.changes().id(r.getChangeId()).get();
     assertReviewers(c, REVIEWER, user);
     assertReviewers(c, CC);
-    label = c.labels.get("Code-Review");
+    label = c.labels.get(LabelId.CODE_REVIEW);
     assertThat(label).isNotNull();
     assertThat(label.all).isNotNull();
     assertThat(label.all).hasSize(1);
@@ -366,7 +368,7 @@
     c = gApi.changes().id(r.getChangeId()).get();
     assertReviewers(c, REVIEWER, user);
     assertReviewers(c, CC);
-    label = c.labels.get("Code-Review");
+    label = c.labels.get(LabelId.CODE_REVIEW);
     assertThat(label).isNotNull();
     assertThat(label.all).isNotNull();
     assertThat(label.all).hasSize(1);
@@ -600,7 +602,7 @@
 
   @Test
   public void removingReviewerRemovesTheirVote() throws Exception {
-    String crLabel = "Code-Review";
+    String crLabel = LabelId.CODE_REVIEW;
     PushOneCommit.Result r = createChange();
     ReviewInput input = ReviewInput.approve().reviewer(admin.email());
     ReviewResult addResult = review(r.getChangeId(), r.getCommit().name(), input);
@@ -657,13 +659,124 @@
   }
 
   @Test
+  public void removeReviewerWithVoteOnMergedChangeForChangeOwnerFails() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, 1));
+
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, 2));
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    assertThat(gApi.changes().id(r.getChangeId()).get().removableReviewers).isEmpty();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove());
+    assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
+  }
+
+  @Test
+  public void removeReviewerWithVoteOnMergedChangeForUserFails() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, 1));
+
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, 2));
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    requestScopeOperations.setApiUser(user.id());
+    assertThat(gApi.changes().id(r.getChangeId()).get().removableReviewers).isEmpty();
+
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove());
+    assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
+  }
+
+  @Test
+  public void removeReviewerWithoutVoteOnMergedChangeForChangeOwnerSucceeds() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
+
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, 2));
+    gApi.changes().id(r.getChangeId()).current().submit();
+    assertThat(
+            Iterables.getOnlyElement(gApi.changes().id(r.getChangeId()).get().removableReviewers)
+                .email)
+        .isEqualTo(user.email());
+
+    gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove();
+
+    // Admin is a "reviewer" since the admin submitted the change, this ensures user is not a
+    // reviewer.
+    assertThat(
+            Iterables.getOnlyElement(
+                    gApi.changes().id(r.getChangeId()).get().reviewers.get(REVIEWER))
+                .email)
+        .doesNotMatch(user.email());
+  }
+
+  @Test
+  public void removeReviewerWithoutVoteOnMergedChangeForUserSucceeds() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
+
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, 2));
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    requestScopeOperations.setApiUser(user.id());
+    assertThat(
+            Iterables.getOnlyElement(gApi.changes().id(r.getChangeId()).get().removableReviewers)
+                .email)
+        .isEqualTo(user.email());
+
+    gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove();
+
+    // Admin is a "reviewer" since the admin submitted the change, this ensures user is not a
+    // reviewer.
+    assertThat(
+            Iterables.getOnlyElement(
+                    gApi.changes().id(r.getChangeId()).get().reviewers.get(REVIEWER))
+                .email)
+        .doesNotMatch(user.email());
+  }
+
+  @Test
   public void removeReviewerWithVoteWithoutPermissionFails() throws Exception {
     PushOneCommit.Result r = createChange();
     TestAccount newUser = createAccounts(1, name("foo")).get(0);
 
     requestScopeOperations.setApiUser(user.id());
-    gApi.changes().id(r.getChangeId()).current().review(new ReviewInput().label("Code-Review", 1));
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, 1));
     requestScopeOperations.setApiUser(newUser.id());
+    assertThat(gApi.changes().id(r.getChangeId()).get().removableReviewers).isEmpty();
+
     AuthException thrown =
         assertThrows(
             AuthException.class,
@@ -687,17 +800,53 @@
     gApi.changes().id(r.getChangeId()).addReviewer(user.email());
     assertThatUserIsOnlyReviewer(r.getChangeId());
     requestScopeOperations.setApiUser(newUser.id());
+    assertThat(
+            Iterables.getOnlyElement(gApi.changes().id(r.getChangeId()).get().removableReviewers)
+                .email)
+        .isEqualTo(user.email());
+
     gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove();
     assertThat(gApi.changes().id(r.getChangeId()).get().reviewers).isEmpty();
   }
 
   @Test
+  @Sandboxed
+  public void removeReviewerWithoutVoteOnAMergedChangeWithPermissionSucceeds() throws Exception {
+    PushOneCommit.Result r = createChange();
+    // This test creates a new user so that it can explicitly check the REMOVE_REVIEWER permission
+    // rather than bypassing the check because of project or ref ownership.
+    TestAccount newUser = createAccounts(1, name("foo")).get(0);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REMOVE_REVIEWER).ref(RefNames.REFS + "*").group(REGISTERED_USERS))
+        .update();
+
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    requestScopeOperations.setApiUser(newUser.id());
+
+    // Ensures user is removable.
+    assertThat(
+            gApi.changes().id(r.getChangeId()).get().removableReviewers.stream()
+                .filter(a -> user.email().equals(a.email))
+                .findAny()
+                .isPresent())
+        .isTrue();
+    gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove();
+  }
+
+  @Test
   public void removeReviewerWithoutVoteWithoutPermissionFails() throws Exception {
     PushOneCommit.Result r = createChange();
     TestAccount newUser = createAccounts(1, name("foo")).get(0);
 
     gApi.changes().id(r.getChangeId()).addReviewer(user.email());
     requestScopeOperations.setApiUser(newUser.id());
+    assertThat(gApi.changes().id(r.getChangeId()).get().removableReviewers).isEmpty();
+
     AuthException thrown =
         assertThrows(
             AuthException.class,
@@ -715,6 +864,8 @@
     input.state = ReviewerState.CC;
     gApi.changes().id(r.getChangeId()).addReviewer(input);
     requestScopeOperations.setApiUser(newUser.id());
+    assertThat(gApi.changes().id(r.getChangeId()).get().removableReviewers).isEmpty();
+
     AuthException thrown =
         assertThrows(
             AuthException.class,
@@ -789,7 +940,11 @@
     projectOperations
         .project(project)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, 2))
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
         .update();
 
     // Create a change and add 'user' as reviewer.
@@ -808,7 +963,7 @@
     requestScopeOperations.setApiUser(user.id());
     approve(changeId);
     c = gApi.changes().id(changeId).get();
-    assertThat(c.labels.get("Code-Review").approved._accountId).isEqualTo(user.id().get());
+    assertThat(c.labels.get(LabelId.CODE_REVIEW).approved._accountId).isEqualTo(user.id().get());
 
     // Move 'user' from reviewer to CC.
     requestScopeOperations.setApiUser(admin.id());
@@ -826,7 +981,7 @@
     assertThat(c.reviewers.get(REVIEWER)).isNull();
 
     // Verify that the approval of 'user' is still there.
-    assertThat(c.labels.get("Code-Review").approved._accountId).isEqualTo(user.id().get());
+    assertThat(c.labels.get(LabelId.CODE_REVIEW).approved._accountId).isEqualTo(user.id().get());
   }
 
   private void assertThatUserIsOnlyReviewer(String changeId) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java
index 3b26459..d3dd801 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java
@@ -204,7 +204,7 @@
   public void crossDomainPutTopic() throws Exception {
     Result change = createChange();
     BasicCookieStore cookies = new BasicCookieStore();
-    Executor http = Executor.newInstance().cookieStore(cookies);
+    Executor http = Executor.newInstance().use(cookies);
 
     Request req = Request.Get(canonicalWebUrl.get() + "/login/?account_id=" + admin.id().get());
     http.execute(req);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index 7fe2a50..129b546 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.entities.Permission.CREATE;
 import static com.google.gerrit.entities.Permission.READ;
 import static com.google.gerrit.entities.RefNames.changeMetaRef;
 import static com.google.gerrit.extensions.common.testing.GitPersonSubject.assertThat;
@@ -180,6 +182,77 @@
   }
 
   @Test
+  public void cannotCreateChangeOnGerritInternalRefs() throws Exception {
+    requestScopeOperations.setApiUser(admin.id());
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(CREATE).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    ChangeInput ci = newChangeInput(ChangeStatus.NEW);
+    ci.subject = "Subject";
+    ci.branch = "refs/changes/00/1000"; // disallowedRef
+
+    Throwable thrown = assertThrows(RestApiException.class, () -> gApi.changes().create(ci));
+    assertThat(thrown).hasMessageThat().contains("Cannot create a change on ref " + ci.branch);
+  }
+
+  @Test
+  public void cannotCreateChangeOnTagRefs() throws Exception {
+    requestScopeOperations.setApiUser(admin.id());
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(CREATE).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    ChangeInput ci = newChangeInput(ChangeStatus.NEW);
+    ci.subject = "Subject";
+    ci.branch = "refs/tags/v1.0"; // disallowed ref
+
+    Throwable thrown = assertThrows(RestApiException.class, () -> gApi.changes().create(ci));
+    assertThat(thrown).hasMessageThat().contains("Cannot create a change on ref " + ci.branch);
+  }
+
+  @Test
+  public void canCreateChangeOnRefsMetaConfig() throws Exception {
+    requestScopeOperations.setApiUser(admin.id());
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(CREATE).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.setApiUser(user.id());
+    ChangeInput ci = newChangeInput(ChangeStatus.NEW);
+    ci.subject = "Subject";
+    ci.branch = RefNames.REFS_CONFIG;
+    assertThat(gApi.changes().create(ci).info().branch).isEqualTo(RefNames.REFS_CONFIG);
+  }
+
+  @Test
+  public void canCreateChangeOnRefsMetaDashboards() throws Exception {
+    String branchName = "refs/meta/dashboards/project_1";
+    requestScopeOperations.setApiUser(admin.id());
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(CREATE).ref(branchName).group(REGISTERED_USERS))
+        .add(allow(READ).ref(branchName).group(REGISTERED_USERS))
+        .update();
+    BranchNameKey branchNameKey = BranchNameKey.create(project, branchName);
+    createBranch(branchNameKey);
+    requestScopeOperations.setApiUser(user.id());
+    ChangeInput ci = newChangeInput(ChangeStatus.NEW);
+    ci.subject = "Subject";
+    ci.branch = branchName;
+    assertThat(gApi.changes().create(ci).info().branch).isEqualTo(branchName);
+  }
+
+  @Test
   public void cannotCreateChangeWithChangeIfOfExistingChangeOnSameBranch() throws Exception {
     String changeId = createChange().getChangeId();
 
@@ -708,6 +781,9 @@
     projectOperations
         .project(project)
         .forUpdate()
+        // Allow reading for refs/meta/config so that the project is visible to the user. Otherwise
+        // the request will fail with an UnprocessableEntityException "Project not found:".
+        .add(allow(READ).ref("refs/meta/config").group(REGISTERED_USERS))
         .add(block(READ).ref("refs/heads/*").group(REGISTERED_USERS))
         .update();
     requestScopeOperations.setApiUser(user.id());
@@ -731,6 +807,9 @@
     projectOperations
         .project(project)
         .forUpdate()
+        // Allow reading for refs/meta/config so that the project is visible to the user. Otherwise
+        // the request will fail with an UnprocessableEntityException "Project not found:".
+        .add(allow(READ).ref("refs/meta/config").group(REGISTERED_USERS))
         .add(block(READ).ref("refs/heads/*").group(REGISTERED_USERS))
         .update();
     requestScopeOperations.setApiUser(user.id());
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
index 25e5647..0c2c3a1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -92,7 +93,7 @@
     Map<String, Short> m =
         newGson().fromJson(response.getReader(), new TypeToken<Map<String, Short>>() {}.getType());
 
-    assertThat(m).containsExactly("Code-Review", Short.valueOf((short) 0));
+    assertThat(m).containsExactly(LabelId.CODE_REVIEW, Short.valueOf((short) 0));
 
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
 
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/MoveChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
index d5881ea..e94b660 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
@@ -16,20 +16,25 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.config.GerritConfig;
 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.LabelFunction;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.changes.MoveInput;
@@ -190,7 +195,7 @@
         .update();
     AuthException thrown =
         assertThrows(AuthException.class, () -> move(r.getChangeId(), newBranch.branch()));
-    assertThat(thrown).hasMessageThat().contains("move not permitted");
+    assertThat(thrown).hasMessageThat().isEqualTo("move not permitted");
   }
 
   @Test
@@ -210,7 +215,7 @@
     requestScopeOperations.setApiUser(user.id());
     AuthException thrown =
         assertThrows(AuthException.class, () -> move(r.getChangeId(), newBranch.branch()));
-    assertThat(thrown).hasMessageThat().contains("move not permitted");
+    assertThat(thrown).hasMessageThat().isEqualTo("move not permitted");
   }
 
   @Test
@@ -269,12 +274,230 @@
   }
 
   @Test
+  public void moveChangeKeepAllVotesOnlyAllowedForAdmins() throws Exception {
+    // Keep all votes options is only permitted for admins.
+    BranchNameKey destinationBranch = BranchNameKey.create(project, "dest");
+    createBranch(destinationBranch);
+    BranchNameKey sourceBranch = BranchNameKey.create(project, "source");
+    createBranch(sourceBranch);
+    String changeId = createChangeInBranch(sourceBranch.branch()).getChangeId();
+
+    // Grant change permissions to the registered users.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref(destinationBranch.branch()).group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.ABANDON).ref(sourceBranch.branch()).group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    AuthException thrown =
+        assertThrows(
+            AuthException.class, () -> move(changeId, destinationBranch.shortName(), true));
+    assertThat(thrown).hasMessageThat().isEqualTo("move is not permitted with keepAllVotes option");
+
+    requestScopeOperations.setApiUser(admin.id());
+
+    move(changeId, destinationBranch.branch(), true);
+    assertThat(info(changeId).branch).isEqualTo(destinationBranch.shortName());
+  }
+
+  @Test
+  public void moveChangeKeepAllVotesNoLabelInDestination() throws Exception {
+    BranchNameKey destinationBranch = BranchNameKey.create(project, "dest");
+    createBranch(destinationBranch);
+    BranchNameKey sourceBranch = BranchNameKey.create(project, "source");
+    createBranch(sourceBranch);
+
+    String testLabelA = "Label-A";
+    // The label has the range [-1; 1]
+    configLabel(testLabelA, LabelFunction.NO_BLOCK, ImmutableList.of(sourceBranch.branch()));
+    // Registered users have permissions for the entire range [-1; 1] on all branches.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(testLabelA).ref("refs/heads/*").group(REGISTERED_USERS).range(-1, +1))
+        .update();
+
+    String changeId = createChangeInBranch(sourceBranch.branch()).getChangeId();
+    requestScopeOperations.setApiUser(user.id());
+    ReviewInput userReviewInput = new ReviewInput();
+    userReviewInput.label(testLabelA, 1);
+    gApi.changes().id(changeId).current().review(userReviewInput);
+
+    assertLabelVote(user, changeId, testLabelA, (short) 1);
+
+    requestScopeOperations.setApiUser(admin.id());
+    assertThat(atrScope.get().getUser().getAccountId()).isEqualTo(admin.id());
+
+    // Move the change to the destination branch.
+    assertThat(info(changeId).branch).isEqualTo(sourceBranch.shortName());
+    move(changeId, destinationBranch.shortName(), true);
+    assertThat(info(changeId).branch).isEqualTo(destinationBranch.shortName());
+
+    // Label is missing in the destination branch.
+    assertThat(gApi.changes().id(changeId).current().reviewer(user.email()).votes()).isEmpty();
+
+    // Move the change back to the source, the label is kept.
+    move(changeId, sourceBranch.shortName(), true);
+    assertThat(info(changeId).branch).isEqualTo(sourceBranch.shortName());
+    assertLabelVote(user, changeId, testLabelA, (short) 1);
+  }
+
+  @Test
+  public void moveChangeKeepAllVotesOutOfUserPermissionRange() throws Exception {
+    BranchNameKey destinationBranch = BranchNameKey.create(project, "dest");
+    createBranch(destinationBranch);
+    BranchNameKey sourceBranch = BranchNameKey.create(project, "source");
+    createBranch(sourceBranch);
+
+    String testLabelA = "Label-A";
+    // The label has the range [-2; 2]
+    configLabel(
+        project,
+        testLabelA,
+        LabelFunction.NO_BLOCK,
+        value(2, "Passes"),
+        value(1, "Mostly ok"),
+        value(0, "No score"),
+        value(-1, "Needs work"),
+        value(-2, "Failed"));
+    // Registered users have [-2; 2] permissions on the source.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(testLabelA).ref(sourceBranch.branch()).group(REGISTERED_USERS).range(-2, +2))
+        .update();
+
+    // Registered users have [-1; 1] permissions on the destination.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(testLabelA)
+                .ref(destinationBranch.branch())
+                .group(REGISTERED_USERS)
+                .range(-1, +1))
+        .update();
+
+    String changeId = createChangeInBranch(sourceBranch.branch()).getChangeId();
+    requestScopeOperations.setApiUser(user.id());
+    // Vote within the range of the source branch.
+    ReviewInput userReviewInput = new ReviewInput();
+    userReviewInput.label(testLabelA, 2);
+    gApi.changes().id(changeId).current().review(userReviewInput);
+
+    assertLabelVote(user, changeId, testLabelA, (short) 2);
+
+    requestScopeOperations.setApiUser(admin.id());
+    assertThat(atrScope.get().getUser().getAccountId()).isEqualTo(admin.id());
+
+    // Move the change to the destination branch.
+    assertThat(info(changeId).branch).isEqualTo(sourceBranch.shortName());
+    move(changeId, destinationBranch.branch(), true);
+    // User does not have label permissions for the same vote on the destination branch.
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(changeId).current().review(userReviewInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(String.format("Applying label \"%s\": 2 is restricted", testLabelA));
+
+    // Label is kept even though the user's permission range is different from the source.
+    // Since we do not squash users votes based on the destination branch access label
+    // configuration, this is working as intended.
+    // It's the same behavior as when a project owner reduces user's permission range on label.
+    // Administrators should take this into account.
+    assertThat(info(changeId).branch).isEqualTo(destinationBranch.shortName());
+    assertLabelVote(user, changeId, testLabelA, (short) 2);
+
+    requestScopeOperations.setApiUser(admin.id());
+    // Move the change back to the source, the label is kept.
+    move(changeId, sourceBranch.shortName(), true);
+    assertThat(info(changeId).branch).isEqualTo(sourceBranch.shortName());
+    assertLabelVote(user, changeId, testLabelA, (short) 2);
+  }
+
+  @Test
+  public void moveKeepAllVotesCanMoveAllInRange() throws Exception {
+    BranchNameKey destinationBranch = BranchNameKey.create(project, "dest");
+    createBranch(destinationBranch);
+    BranchNameKey sourceBranch = BranchNameKey.create(project, "source");
+    createBranch(sourceBranch);
+
+    // The non-block label has the range [-2; 2]
+    String testLabelA = "Label-A";
+    configLabel(
+        project,
+        testLabelA,
+        LabelFunction.NO_BLOCK,
+        value(2, "Passes"),
+        value(1, "Mostly ok"),
+        value(0, "No score"),
+        value(-1, "Needs work"),
+        value(-2, "Failed"));
+
+    // Registered users have [-2; 2] permissions on all branches.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(testLabelA).ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +2))
+        .update();
+
+    String changeId = createChangeInBranch(sourceBranch.branch()).getChangeId();
+
+    for (int vote = -2; vote <= 2; vote++) {
+      TestAccount testUser = accountCreator.create("TestUser" + vote);
+      requestScopeOperations.setApiUser(testUser.id());
+      ReviewInput userReviewInput = new ReviewInput();
+      userReviewInput.label(testLabelA, vote);
+      gApi.changes().id(changeId).current().review(userReviewInput);
+    }
+
+    assertThat(
+            gApi.changes().id(changeId).current().votes().get(testLabelA).stream()
+                .map(approvalInfo -> approvalInfo.value)
+                .collect(ImmutableList.toImmutableList()))
+        .containsExactly(-2, -1, 1, 2);
+
+    requestScopeOperations.setApiUser(admin.id());
+    // Move the change to the destination branch.
+    assertThat(info(changeId).branch).isEqualTo(sourceBranch.shortName());
+    move(changeId, destinationBranch.shortName(), true);
+    assertThat(info(changeId).branch).isEqualTo(destinationBranch.shortName());
+
+    // All votes are kept
+    assertThat(
+            gApi.changes().id(changeId).current().votes().get(testLabelA).stream()
+                .map(approvalInfo -> approvalInfo.value)
+                .collect(ImmutableList.toImmutableList()))
+        .containsExactly(-2, -1, 1, 2);
+
+    // Move the change back to the source, the label is kept.
+    move(changeId, sourceBranch.shortName(), true);
+    assertThat(info(changeId).branch).isEqualTo(sourceBranch.shortName());
+    assertThat(
+            gApi.changes().id(changeId).current().votes().get(testLabelA).stream()
+                .map(approvalInfo -> approvalInfo.value)
+                .collect(ImmutableList.toImmutableList()))
+        .containsExactly(-2, -1, 1, 2);
+  }
+
+  @Test
   public void moveChangeOnlyKeepVetoVotes() throws Exception {
     // A vote for a label will be kept after moving if the label's function is *WithBlock and the
     // vote holds the minimum value.
     createBranch(BranchNameKey.create(project, "foo"));
 
-    String codeReviewLabel = "Code-Review"; // 'Code-Review' uses 'MaxWithBlock' function.
+    String codeReviewLabel = LabelId.CODE_REVIEW; // 'Code-Review' uses 'MaxWithBlock' function.
     String testLabelA = "Label-A";
     String testLabelB = "Label-B";
     String testLabelC = "Label-C";
@@ -394,10 +617,28 @@
     gApi.changes().id(changeId).move(in);
   }
 
+  private void move(String changeId, String destination, boolean keepAllVotes)
+      throws RestApiException {
+    MoveInput in = new MoveInput();
+    in.destinationBranch = destination;
+    in.keepAllVotes = keepAllVotes;
+    gApi.changes().id(changeId).move(in);
+  }
+
   private PushOneCommit.Result createChange(String branch, String changeId) throws Exception {
     PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo, changeId);
     PushOneCommit.Result result = push.to("refs/for/" + branch);
     result.assertOkStatus();
     return result;
   }
+
+  private PushOneCommit.Result createChangeInBranch(String branch) throws Exception {
+    return createChange("refs/for/" + branch);
+  }
+
+  private void assertLabelVote(TestAccount user, String changeId, String label, short vote)
+      throws Exception {
+    assertThat(gApi.changes().id(changeId).current().reviewer(user.email()).votes())
+        .containsEntry(label, vote);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java
index 17bf37e..a4ec40e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.acceptance.rest.change;
 
-import static com.google.common.truth.Truth.assertThat;
-
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.gerrit.acceptance.AbstractPluginFieldsTest;
@@ -35,41 +33,6 @@
   private static final Gson GSON = OutputFormat.JSON.newGson();
 
   @Test
-  public void queryChangeWithNullAttribute() throws Exception {
-    getChangeWithNullAttribute(
-        id -> pluginInfoFromSingletonList(adminRestSession.get(changeQueryUrl(id))));
-  }
-
-  @Test
-  public void getChangeWithNullAttribute() throws Exception {
-    getChangeWithNullAttribute(id -> pluginInfoFromChangeInfo(adminRestSession.get(changeUrl(id))));
-  }
-
-  @Test
-  public void getChangeDetailWithNullAttribute() throws Exception {
-    getChangeWithNullAttribute(
-        id -> pluginInfoFromChangeInfo(adminRestSession.get(changeDetailUrl(id))));
-  }
-
-  @Test
-  public void queryChangeWithSimpleAttribute() throws Exception {
-    getChangeWithSimpleAttribute(
-        id -> pluginInfoFromSingletonList(adminRestSession.get(changeQueryUrl(id))));
-  }
-
-  @Test
-  public void getChangeWithSimpleAttribute() throws Exception {
-    getChangeWithSimpleAttribute(
-        id -> pluginInfoFromChangeInfo(adminRestSession.get(changeUrl(id))));
-  }
-
-  @Test
-  public void getChangeDetailWithSimpleAttribute() throws Exception {
-    getChangeWithSimpleAttribute(
-        id -> pluginInfoFromChangeInfo(adminRestSession.get(changeDetailUrl(id))));
-  }
-
-  @Test
   public void querySingleChangeWithBulkAttribute() throws Exception {
     getSingleChangeWithPluginDefinedBulkAttribute(
         id -> pluginInfosFromChangeInfos(adminRestSession.get(changeQueryUrl(id))));
@@ -88,27 +51,6 @@
   }
 
   @Test
-  public void queryChangeWithOption() throws Exception {
-    getChangeWithOption(
-        id -> pluginInfoFromSingletonList(adminRestSession.get(changeQueryUrl(id))),
-        (id, opts) -> pluginInfoFromSingletonList(adminRestSession.get(changeQueryUrl(id, opts))));
-  }
-
-  @Test
-  public void getChangeWithOption() throws Exception {
-    getChangeWithOption(
-        id -> pluginInfoFromChangeInfo(adminRestSession.get(changeUrl(id))),
-        (id, opts) -> pluginInfoFromChangeInfo(adminRestSession.get(changeUrl(id, opts))));
-  }
-
-  @Test
-  public void getChangeDetailWithOption() throws Exception {
-    getChangeWithOption(
-        id -> pluginInfoFromChangeInfo(adminRestSession.get(changeDetailUrl(id))),
-        (id, opts) -> pluginInfoFromChangeInfo(adminRestSession.get(changeDetailUrl(id, opts))));
-  }
-
-  @Test
   public void pluginDefinedQueryChangeWithOption() throws Exception {
     getChangeWithPluginDefinedBulkAttributeOption(
         id -> pluginInfosFromChangeInfos(adminRestSession.get(changeQueryUrl(id))),
@@ -142,12 +84,6 @@
   }
 
   @Test
-  public void getMultipleChangesWithPluginDefinedAndChangeAttributes() throws Exception {
-    getMultipleChangesWithPluginDefinedBulkAndChangeAttributes(
-        () -> pluginInfosFromChangeInfos(adminRestSession.get("/changes/?q=status:open")));
-  }
-
-  @Test
   public void getMultipleChangesWithPluginDefinedAttributeInSingleCall() throws Exception {
     getMultipleChangesWithPluginDefinedBulkAttributeInSingleCall(
         () -> pluginInfosFromChangeInfos(adminRestSession.get("/changes/?q=status:open")));
@@ -204,24 +140,6 @@
   }
 
   @Nullable
-  private static List<PluginDefinedInfo> pluginInfoFromSingletonList(RestResponse res)
-      throws Exception {
-    res.assertOK();
-    List<Map<String, Object>> changeInfos =
-        GSON.fromJson(res.getReader(), new TypeToken<List<Map<String, Object>>>() {}.getType());
-    assertThat(changeInfos).hasSize(1);
-    return decodeRawPluginsList(GSON, changeInfos.get(0).get("plugins"));
-  }
-
-  @Nullable
-  private List<PluginDefinedInfo> pluginInfoFromChangeInfo(RestResponse res) throws Exception {
-    res.assertOK();
-    Map<String, Object> changeInfo =
-        GSON.fromJson(res.getReader(), new TypeToken<Map<String, Object>>() {}.getType());
-    return decodeRawPluginsList(GSON, changeInfo.get("plugins"));
-  }
-
-  @Nullable
   private Map<Change.Id, List<PluginDefinedInfo>> pluginInfoMapFromChangeInfo(RestResponse res)
       throws Exception {
     res.assertOK();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
index 6f519f1..fff3cb6 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
@@ -25,12 +25,14 @@
 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.BranchNameKey;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.submit.CommitMergeStatus;
 import com.google.inject.Inject;
 import java.util.List;
@@ -88,12 +90,18 @@
   @Test
   public void changeMessageOnSubmit() throws Throwable {
     PushOneCommit.Result change = createChange();
-    try (Registration registration =
-        extensionRegistry
-            .newRegistration()
-            .add(
-                (newCommitMessage, original, mergeTip, destination) ->
-                    newCommitMessage + "Custom: " + destination.branch())) {
+    ChangeMessageModifier link =
+        new ChangeMessageModifier() {
+          @Override
+          public String onSubmit(
+              String newCommitMessage,
+              RevCommit original,
+              RevCommit mergeTip,
+              BranchNameKey destination) {
+            return newCommitMessage + "Custom: " + destination.branch();
+          }
+        };
+    try (Registration registration = extensionRegistry.newRegistration().add(link)) {
       submit(change.getChangeId());
     }
     testRepo.git().fetch().setRemote("origin").call();
@@ -373,4 +381,26 @@
         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");
+
+    // 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..157c93c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
@@ -29,7 +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.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
@@ -534,48 +534,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");
@@ -614,7 +572,11 @@
     projectOperations
         .project(project)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, 2))
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
         .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
         .update();
 
@@ -676,7 +638,11 @@
     projectOperations
         .project(project)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, 2))
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
         .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
         .update();
 
@@ -733,13 +699,21 @@
     projectOperations
         .project(p1)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, 2))
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
         .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
         .update();
     projectOperations
         .project(p2)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, 2))
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
         .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
         .update();
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
index d742fad..eeeac2a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.exceptions.InternalServerWithUserMessageException;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -129,7 +129,8 @@
     ChangeMessageModifier modifier2 = (msg, orig, tip, dest) -> msg + "A-footer: value\n";
     try (Registration registration =
         extensionRegistry.newRegistration().add(modifier1).add(modifier2)) {
-      StorageException thrown = assertThrows(StorageException.class, () -> submitWithRebase());
+      InternalServerWithUserMessageException thrown =
+          assertThrows(InternalServerWithUserMessageException.class, () -> submitWithRebase());
       Throwable cause = Throwables.getRootCause(thrown);
       assertThat(cause).isInstanceOf(RuntimeException.class);
       assertThat(cause).hasMessageThat().isEqualTo("boom");
@@ -145,7 +146,8 @@
             .newRegistration()
             .add(modifier1, "modifier-1")
             .add(modifier2, "modifier-2")) {
-      StorageException thrown = assertThrows(StorageException.class, () -> submitWithRebase());
+      InternalServerWithUserMessageException thrown =
+          assertThrows(InternalServerWithUserMessageException.class, () -> submitWithRebase());
       Throwable cause = Throwables.getRootCause(thrown);
       assertThat(cause).isInstanceOf(RuntimeException.class);
       assertThat(cause)
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
index cef66654..4738f64 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
@@ -58,7 +58,6 @@
   @GerritConfig(name = "auth.httpPasswordUrl", value = "https://example.com/password")
 
   // change
-  @GerritConfig(name = "change.largeChange", value = "300")
   @GerritConfig(name = "change.replyTooltip", value = "Publish votes and draft comments")
   @GerritConfig(name = "change.replyLabel", value = "Vote")
   @GerritConfig(name = "change.updateDelay", value = "50s")
@@ -75,6 +74,7 @@
   @GerritConfig(name = "gerrit.allProjects", value = "Root")
   @GerritConfig(name = "gerrit.allUsers", value = "Users")
   @GerritConfig(name = "gerrit.reportBugUrl", value = "https://example.com/report")
+  @GerritConfig(name = "gerrit.instanceId", value = "devops-instance")
 
   // suggest
   @GerritConfig(name = "suggest.from", value = "3")
@@ -102,7 +102,6 @@
     assertThat(i.auth.httpPasswordUrl).isNull();
 
     // change
-    assertThat(i.change.largeChange).isEqualTo(300);
     assertThat(i.change.replyTooltip).startsWith("Publish votes and draft comments");
     assertThat(i.change.replyLabel).isEqualTo("Vote\u2026");
     assertThat(i.change.updateDelay).isEqualTo(50);
@@ -118,6 +117,7 @@
     assertThat(i.gerrit.allProjects).isEqualTo("Root");
     assertThat(i.gerrit.allUsers).isEqualTo("Users");
     assertThat(i.gerrit.reportBugUrl).isEqualTo("https://example.com/report");
+    assertThat(i.gerrit.instanceId).isEqualTo("devops-instance");
 
     // plugin
     assertThat(i.plugin.jsResourcePaths).isEmpty();
@@ -170,7 +170,6 @@
     assertThat(i.auth.httpPasswordUrl).isNull();
 
     // change
-    assertThat(i.change.largeChange).isEqualTo(500);
     assertThat(i.change.replyTooltip).startsWith("Reply and score");
     assertThat(i.change.replyLabel).isEqualTo("Reply\u2026");
     assertThat(i.change.updateDelay).isEqualTo(300);
@@ -187,6 +186,7 @@
     assertThat(i.gerrit.allProjects).isEqualTo(AllProjectsNameProvider.DEFAULT);
     assertThat(i.gerrit.allUsers).isEqualTo(AllUsersNameProvider.DEFAULT);
     assertThat(i.gerrit.reportBugUrl).isNull();
+    assertThat(i.gerrit.instanceId).isNull();
 
     // plugin
     assertThat(i.plugin.jsResourcePaths).isEmpty();
diff --git a/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java b/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
index 9c17a5a..4453345 100644
--- a/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gson.reflect.TypeToken;
 import java.util.Map;
 import org.junit.Test;
@@ -32,6 +33,7 @@
     Map<String, GroupInfo> groupMap =
         newGson()
             .fromJson(response.getReader(), new TypeToken<Map<String, GroupInfo>>() {}.getType());
-    assertThat(groupMap.keySet()).containsExactly("Administrators", "Service Users");
+    assertThat(groupMap.keySet())
+        .containsExactly("Administrators", ServiceUserClassifier.SERVICE_USERS);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
index 33d0d29..ff4f203 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
@@ -78,8 +79,9 @@
 
   private static final String REFS_ALL = Constants.R_REFS + "*";
   private static final String REFS_HEADS = Constants.R_HEADS + "*";
-
-  private static final String LABEL_CODE_REVIEW = "Code-Review";
+  private static final String REFS_META_VERSION = "refs/meta/version";
+  private static final String REFS_DRAFTS = "refs/draft-comments/*";
+  private static final String REFS_STARRED_CHANGES = "refs/starred-changes/*";
 
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
@@ -415,13 +417,13 @@
     // Remove specific permission
     AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
     accessSectionToRemove.permissions.put(
-        Permission.LABEL + LABEL_CODE_REVIEW, newPermissionInfo());
+        Permission.LABEL + LabelId.CODE_REVIEW, newPermissionInfo());
     ProjectAccessInput removal = newProjectAccessInput();
     removal.remove.put(REFS_HEADS, accessSectionToRemove);
     pApi().access(removal);
 
     // Remove locally
-    accessInput.add.get(REFS_HEADS).permissions.remove(Permission.LABEL + LABEL_CODE_REVIEW);
+    accessInput.add.get(REFS_HEADS).permissions.remove(Permission.LABEL + LabelId.CODE_REVIEW);
 
     // Check
     assertThat(pApi().access().local).isEqualTo(accessInput.add);
@@ -439,10 +441,10 @@
     // Remove specific permission rule
     AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
     PermissionInfo codeReview = newPermissionInfo();
-    codeReview.label = LABEL_CODE_REVIEW;
+    codeReview.label = LabelId.CODE_REVIEW;
     PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
     codeReview.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    accessSectionToRemove.permissions.put(Permission.LABEL + LABEL_CODE_REVIEW, codeReview);
+    accessSectionToRemove.permissions.put(Permission.LABEL + LabelId.CODE_REVIEW, codeReview);
     ProjectAccessInput removal = newProjectAccessInput();
     removal.remove.put(REFS_HEADS, accessSectionToRemove);
     pApi().access(removal);
@@ -452,7 +454,7 @@
         .add
         .get(REFS_HEADS)
         .permissions
-        .get(Permission.LABEL + LABEL_CODE_REVIEW)
+        .get(Permission.LABEL + LabelId.CODE_REVIEW)
         .rules
         .remove(SystemGroupBackend.REGISTERED_USERS.get());
 
@@ -472,18 +474,18 @@
     // Remove specific permission rules
     AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
     PermissionInfo codeReview = newPermissionInfo();
-    codeReview.label = LABEL_CODE_REVIEW;
+    codeReview.label = LabelId.CODE_REVIEW;
     PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
     codeReview.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
     pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
     codeReview.rules.put(SystemGroupBackend.PROJECT_OWNERS.get(), pri);
-    accessSectionToRemove.permissions.put(Permission.LABEL + LABEL_CODE_REVIEW, codeReview);
+    accessSectionToRemove.permissions.put(Permission.LABEL + LabelId.CODE_REVIEW, codeReview);
     ProjectAccessInput removal = newProjectAccessInput();
     removal.remove.put(REFS_HEADS, accessSectionToRemove);
     pApi().access(removal);
 
     // Remove locally
-    accessInput.add.get(REFS_HEADS).permissions.remove(Permission.LABEL + LABEL_CODE_REVIEW);
+    accessInput.add.get(REFS_HEADS).permissions.remove(Permission.LABEL + LabelId.CODE_REVIEW);
 
     // Check
     assertThat(pApi().access().local).isEqualTo(accessInput.add);
@@ -496,7 +498,10 @@
     AccessSectionInfo accessSectionInfo = createAccessSectionInfoDenyAll();
 
     // Disallow READ
-    accessInput.add.put(REFS_ALL, accessSectionInfo);
+    accessInput.add.put(REFS_META_VERSION, accessSectionInfo);
+    accessInput.add.put(REFS_DRAFTS, accessSectionInfo);
+    accessInput.add.put(REFS_STARRED_CHANGES, accessSectionInfo);
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
     pApi().access(accessInput);
 
     requestScopeOperations.setApiUser(user.id());
@@ -510,7 +515,10 @@
     AccessSectionInfo accessSectionInfo = createAccessSectionInfoDenyAll();
 
     // Disallow READ
-    accessInput.add.put(REFS_ALL, accessSectionInfo);
+    accessInput.add.put(REFS_META_VERSION, accessSectionInfo);
+    accessInput.add.put(REFS_DRAFTS, accessSectionInfo);
+    accessInput.add.put(REFS_STARRED_CHANGES, accessSectionInfo);
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
     pApi().access(accessInput);
 
     // Create a change to apply
@@ -969,7 +977,7 @@
     accessSection.permissions.put(Permission.PUSH, push);
 
     PermissionInfo codeReview = newPermissionInfo();
-    codeReview.label = LABEL_CODE_REVIEW;
+    codeReview.label = LabelId.CODE_REVIEW;
     pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
     codeReview.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
 
@@ -977,7 +985,7 @@
     pri.max = 1;
     pri.min = -1;
     codeReview.rules.put(SystemGroupBackend.PROJECT_OWNERS.get(), pri);
-    accessSection.permissions.put(Permission.LABEL + LABEL_CODE_REVIEW, codeReview);
+    accessSection.permissions.put(Permission.LABEL + LabelId.CODE_REVIEW, codeReview);
 
     return accessSection;
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/BUILD b/javatests/com/google/gerrit/acceptance/rest/project/BUILD
index 5e1fc83..edcb1f9 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/BUILD
+++ b/javatests/com/google/gerrit/acceptance/rest/project/BUILD
@@ -20,7 +20,6 @@
         "LabelAssert.java",
     ],
     deps = [
-        "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server",
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java
index a2f976a..b0320f6 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java
@@ -233,7 +233,11 @@
         .setRefSpecs(new RefSpec("HEAD:refs/heads/master"))
         .call();
 
-    assertBadRequest("master", "fdsafsdf", "recursive", "Cannot resolve 'fdsafsdf' to a commit");
+    assertBadRequest(
+        "master",
+        "fdsafsdf",
+        "recursive",
+        "Error resolving: 'fdsafsdf'. Do not have read permission, or failed to resolve to a commit.");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
index 096c72b..93ce255 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
@@ -207,7 +207,7 @@
   }
 
   @Test
-  public void createUserBranch_Conflict() throws Exception {
+  public void createUserBranch_NotAllowed() throws Exception {
     projectOperations
         .project(allUsers)
         .forUpdate()
@@ -217,12 +217,12 @@
     assertCreateFails(
         BranchNameKey.create(allUsers, RefNames.refsUsers(Account.id(1))),
         RefNames.refsUsers(admin.id()),
-        ResourceConflictException.class,
-        "Not allowed to create user branch.");
+        BadRequestException.class,
+        "Not allowed to create branches under Gerrit internal or tags refs.");
   }
 
   @Test
-  public void createGroupBranch_Conflict() throws Exception {
+  public void createGroupBranch_NotAllowed() throws Exception {
     projectOperations
         .project(allUsers)
         .forUpdate()
@@ -232,8 +232,8 @@
     assertCreateFails(
         BranchNameKey.create(allUsers, RefNames.refsGroups(AccountGroup.uuid("foo"))),
         RefNames.refsGroups(adminGroupUuid()),
-        ResourceConflictException.class,
-        "Not allowed to create group branch.");
+        BadRequestException.class,
+        "Not allowed to create branches under Gerrit internal or tags refs.");
   }
 
   @Test
@@ -355,6 +355,22 @@
   }
 
   @Test
+  public void cannotCreateBranchInGerritInternalRefsNamespace() throws Exception {
+    assertCreateFails(
+        BranchNameKey.create(project, RefNames.REFS_CHANGES + "00/1000"),
+        BadRequestException.class,
+        "Not allowed to create branches under Gerrit internal or tags refs.");
+  }
+
+  @Test
+  public void cannotCreateBranchInTagsNamespace() throws Exception {
+    assertCreateFails(
+        BranchNameKey.create(project, RefNames.REFS_TAGS + "v1.0"),
+        BadRequestException.class,
+        "Not allowed to create branches under Gerrit internal or tags refs.");
+  }
+
+  @Test
   public void cannotCreateBranchWithInvalidName() throws Exception {
     assertCreateFails(
         BranchNameKey.create(project, RefNames.REFS_HEADS),
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
index 94511f8..6a98b8b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
@@ -95,7 +96,7 @@
             () ->
                 gApi.projects()
                     .name(allProjects.get())
-                    .label("Code-Review")
+                    .label(LabelId.CODE_REVIEW)
                     .create(new LabelDefinitionInput()));
     assertThat(thrown).hasMessageThat().contains("label Code-Review already exists");
   }
@@ -240,6 +241,7 @@
     assertThat(createdLabel.copyAnyScore).isNull();
     assertThat(createdLabel.copyMinScore).isNull();
     assertThat(createdLabel.copyMaxScore).isNull();
+    assertThat(createdLabel.copyAllScoresIfListOfFilesDidNotChange).isNull();
     assertThat(createdLabel.copyAllScoresIfNoChange).isTrue();
     assertThat(createdLabel.copyAllScoresIfNoCodeChange).isNull();
     assertThat(createdLabel.copyAllScoresOnTrivialRebase).isNull();
@@ -450,6 +452,28 @@
   }
 
   @Test
+  public void createWithCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyAllScoresIfListOfFilesDidNotChange = true;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyAllScoresIfListOfFilesDidNotChange).isTrue();
+  }
+
+  @Test
+  public void createWithoutCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyAllScoresIfListOfFilesDidNotChange = false;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyAllScoresIfListOfFilesDidNotChange).isNull();
+  }
+
+  @Test
   public void createWithCopyAllScoresIfNoChange() throws Exception {
     LabelDefinitionInput input = new LabelDefinitionInput();
     input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
index 10fd65f..e5c5952 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
@@ -53,6 +53,7 @@
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
 import java.util.Collections;
 import java.util.Optional;
@@ -328,6 +329,21 @@
   }
 
   @Test
+  public void createProjectWithInvalidBranch() throws Exception {
+    String newProjectName = name("newProject");
+    ProjectInput in = new ProjectInput();
+    in.name = newProjectName;
+    in.createEmptyCommit = true;
+    in.branches = ImmutableList.of("refs/heads/test", "refs/changes/34/1234");
+    Throwable thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.projects().create(in));
+    assertThat(thrown).hasCauseThat().isInstanceOf(ValidationException.class);
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Cannot create a project with branch refs/changes/34/1234");
+  }
+
+  @Test
   public void createProjectWithCapability() throws Exception {
     projectOperations
         .allProjectsForUpdate()
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
index c98a58e..ce92536 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
@@ -128,6 +128,7 @@
     projectOperations
         .project(project)
         .forUpdate()
+        .add(allow(Permission.READ).ref(metaRef).group(REGISTERED_USERS))
         .add(allow(Permission.CREATE).ref(metaRef).group(REGISTERED_USERS))
         .add(allow(Permission.PUSH).ref(metaRef).group(REGISTERED_USERS))
         .update();
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
index 57c7b17..c2db9f1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -39,7 +40,7 @@
     AuthException thrown =
         assertThrows(
             AuthException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").delete());
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).delete());
     assertThat(thrown).hasMessageThat().contains("Authentication required");
   }
 
@@ -55,7 +56,7 @@
     AuthException thrown =
         assertThrows(
             AuthException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").delete());
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).delete());
     assertThat(thrown).hasMessageThat().contains("write refs/meta/config not permitted");
   }
 
@@ -70,18 +71,18 @@
 
   @Test
   public void delete() throws Exception {
-    gApi.projects().name(allProjects.get()).label("Code-Review").delete();
+    gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).delete();
 
     ResourceNotFoundException thrown =
         assertThrows(
             ResourceNotFoundException.class,
-            () -> gApi.projects().name(project.get()).label("Code-Review").get());
+            () -> gApi.projects().name(project.get()).label(LabelId.CODE_REVIEW).get());
     assertThat(thrown).hasMessageThat().contains("Not found: Code-Review");
   }
 
   @Test
   public void defaultCommitMessage() throws Exception {
-    gApi.projects().name(allProjects.get()).label("Code-Review").delete();
+    gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).delete();
     assertThat(
             projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
         .isEqualTo("Delete label");
@@ -89,7 +90,10 @@
 
   @Test
   public void withCommitMessage() throws Exception {
-    gApi.projects().name(allProjects.get()).label("Code-Review").delete("Delete Code-Review label");
+    gApi.projects()
+        .name(allProjects.get())
+        .label(LabelId.CODE_REVIEW)
+        .delete("Delete Code-Review label");
     assertThat(
             projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
         .isEqualTo("Delete Code-Review label");
@@ -99,7 +103,7 @@
   public void commitMessageIsTrimmed() throws Exception {
     gApi.projects()
         .name(allProjects.get())
-        .label("Code-Review")
+        .label(LabelId.CODE_REVIEW)
         .delete(" Delete Code-Review label ");
     assertThat(
             projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/FilesInCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/project/FilesInCommitIT.java
new file mode 100644
index 0000000..74ba48e
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/FilesInCommitIT.java
@@ -0,0 +1,137 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.server.restapi.project.FilesInCommitCollection;
+import java.util.Map;
+import java.util.function.Function;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Test class for {@link FilesInCommitCollection}. */
+public class FilesInCommitIT extends AbstractDaemonTest {
+  private String changeId;
+
+  @Before
+  public void setUp() throws Exception {
+    baseConfig.setString("cache", "diff", "timeout", "1 minute");
+
+    ObjectId headCommit = testRepo.getRepository().resolve("HEAD");
+    addCommit(
+        headCommit,
+        ImmutableMap.of("file_1.txt", "file 1 content", "file_2.txt", "file 2 content"));
+
+    Result result = createEmptyChange();
+    changeId = result.getChangeId();
+  }
+
+  @Test
+  public void listFilesForSingleParentCommit() throws Exception {
+    gApi.changes()
+        .id(changeId)
+        .edit()
+        .modifyFile("a_new_file.txt", RawInputUtil.create("Line 1\nLine 2\nLine 3"));
+    gApi.changes().id(changeId).edit().deleteFile("file_1.txt");
+    gApi.changes().id(changeId).edit().publish();
+
+    String lastCommitId = gApi.changes().id(changeId).get().currentRevision;
+
+    // When parentNum is 0, the diff is performed against the default base, i.e. the single parent
+    // in this case.
+    Map<String, FileInfo> changedFiles =
+        gApi.projects().name(project.get()).commit(lastCommitId).files(0);
+
+    assertThat(changedFiles.keySet())
+        .containsExactly("/COMMIT_MSG", "a_new_file.txt", "file_1.txt");
+  }
+
+  @Test
+  public void listFilesForMergeCommitAgainstParent1() throws Exception {
+    PushOneCommit.Result result = createMergeCommitChange("refs/for/master", "my_file.txt");
+
+    String changeId = result.getChangeId();
+    addModifiedPatchSet(changeId, "my_file.txt", content -> content.concat("Line I\nLine II\n"));
+
+    String lastCommitId = gApi.changes().id(changeId).get().currentRevision;
+
+    // Diffing against the first parent.
+    Map<String, FileInfo> changedFiles =
+        gApi.projects().name(project.get()).commit(lastCommitId).files(1);
+
+    assertThat(changedFiles.keySet())
+        .containsExactly(
+            "/COMMIT_MSG",
+            "/MERGE_LIST",
+            "bar", // file bar is coming from parent two
+            "my_file.txt");
+  }
+
+  @Test
+  public void listFilesForMergeCommitAgainstDefaultParent() throws Exception {
+    PushOneCommit.Result result = createMergeCommitChange("refs/for/master", "my_file.txt");
+
+    String changeId = result.getChangeId();
+    addModifiedPatchSet(changeId, "my_file.txt", content -> content.concat("Line I\nLine II\n"));
+
+    String lastCommitId = gApi.changes().id(changeId).get().currentRevision;
+
+    // When parentNum is 0, the diff is performed against the default base. In this case, the
+    // auto-merge commit.
+    Map<String, FileInfo> changedFiles =
+        gApi.projects().name(project.get()).commit(lastCommitId).files(0);
+
+    assertThat(changedFiles.keySet())
+        .containsExactly(
+            "/COMMIT_MSG",
+            "/MERGE_LIST",
+            "bar", // file bar is coming from parent two
+            "my_file.txt");
+  }
+
+  private void addModifiedPatchSet(
+      String changeId, String filePath, Function<String, String> contentModification)
+      throws Exception {
+    try (BinaryResult content = gApi.changes().id(changeId).current().file(filePath).content()) {
+      String newContent = contentModification.apply(content.asString());
+      gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(newContent));
+    }
+    gApi.changes().id(changeId).edit().publish();
+  }
+
+  private ObjectId addCommit(ObjectId parentCommit, ImmutableMap<String, String> files)
+      throws Exception {
+    testRepo.reset(parentCommit);
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "Adjust files of repo", files);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    return result.getCommit();
+  }
+
+  private Result createEmptyChange() throws Exception {
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "Test change", ImmutableMap.of());
+    return push.to("refs/for/master");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetBranchIT.java
index b4b1be0..13c20dd 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetBranchIT.java
@@ -262,9 +262,12 @@
     requestScopeOperations.setApiUser(user.id());
     assertBranchFound(allUsers, RefNames.refsUsers(user.id()));
 
-    // TODO: every user can see the own user ref via the magic ref/users/self ref
-    // requestScopeOperations.setApiUser(user.id());
-    // assertBranchFound(allUsers, RefNames.REFS_USERS_SELF);
+    // every user can see the own user ref via the magic ref/users/self ref. For this special case,
+    // the branch in the request is refs/users/self, but the response contains the actual
+    // refs/users/$sharded_id/$id
+    BranchInfo branchInfo =
+        gApi.projects().name(allUsers.get()).branch(RefNames.REFS_USERS_SELF).get();
+    assertThat(branchInfo.ref).isEqualTo(RefNames.refsUsers(user.id()));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
index a2c5c64..302d827 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -38,7 +39,7 @@
     AuthException thrown =
         assertThrows(
             AuthException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").get());
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get());
     assertThat(thrown).hasMessageThat().contains("Authentication required");
   }
 
@@ -48,7 +49,7 @@
     AuthException thrown =
         assertThrows(
             AuthException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").get());
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get());
     assertThat(thrown).hasMessageThat().contains("read refs/meta/config not permitted");
   }
 
@@ -64,7 +65,7 @@
   @Test
   public void allProjectsCodeReviewLabel() throws Exception {
     LabelDefinitionInfo codeReviewLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").get();
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get();
     LabelAssert.assertCodeReviewLabel(codeReviewLabel);
   }
 
@@ -113,6 +114,7 @@
     assertThat(fooLabel.copyAnyScore).isNull();
     assertThat(fooLabel.copyMinScore).isNull();
     assertThat(fooLabel.copyMaxScore).isNull();
+    assertThat(fooLabel.copyAllScoresIfListOfFilesDidNotChange).isNull();
     assertThat(fooLabel.copyAllScoresIfNoChange).isNull();
     assertThat(fooLabel.copyAllScoresIfNoCodeChange).isNull();
     assertThat(fooLabel.copyAllScoresOnTrivialRebase).isNull();
@@ -135,6 +137,7 @@
                 labelType.setCopyAnyScore(true);
                 labelType.setCopyMinScore(true);
                 labelType.setCopyMaxScore(true);
+                labelType.setCopyAllScoresIfListOfFilesDidNotChange(true);
                 labelType.setCopyAllScoresIfNoCodeChange(true);
                 labelType.setCopyAllScoresOnTrivialRebase(true);
                 labelType.setCopyAllScoresOnMergeFirstParentUpdate(true);
@@ -149,6 +152,7 @@
     assertThat(fooLabel.copyAnyScore).isTrue();
     assertThat(fooLabel.copyMinScore).isTrue();
     assertThat(fooLabel.copyMaxScore).isTrue();
+    assertThat(fooLabel.copyAllScoresIfListOfFilesDidNotChange).isTrue();
     assertThat(fooLabel.copyAllScoresIfNoChange).isTrue();
     assertThat(fooLabel.copyAllScoresIfNoCodeChange).isTrue();
     assertThat(fooLabel.copyAllScoresOnTrivialRebase).isTrue();
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java b/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
index 201bb53..9e31026 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
@@ -17,12 +17,13 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
 
 public class LabelAssert {
   public static void assertCodeReviewLabel(LabelDefinitionInfo codeReviewLabel) {
-    assertThat(codeReviewLabel.name).isEqualTo("Code-Review");
+    assertThat(codeReviewLabel.name).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(codeReviewLabel.projectName).isEqualTo(AllProjectsNameProvider.DEFAULT);
     assertThat(codeReviewLabel.function).isEqualTo(LabelFunction.MAX_WITH_BLOCK.getFunctionName());
     assertThat(codeReviewLabel.values)
@@ -43,6 +44,7 @@
     assertThat(codeReviewLabel.copyAnyScore).isNull();
     assertThat(codeReviewLabel.copyMinScore).isTrue();
     assertThat(codeReviewLabel.copyMaxScore).isNull();
+    assertThat(codeReviewLabel.copyAllScoresIfListOfFilesDidNotChange).isNull();
     assertThat(codeReviewLabel.copyAllScoresIfNoChange).isTrue();
     assertThat(codeReviewLabel.copyAllScoresIfNoCodeChange).isNull();
     assertThat(codeReviewLabel.copyAllScoresOnTrivialRebase).isTrue();
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
index 7535dea..3c8357b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
@@ -16,13 +16,21 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertThatNameList;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.inject.Inject;
 import org.apache.commons.lang.RandomStringUtils;
 import org.junit.Test;
@@ -30,6 +38,8 @@
 @NoHttpd
 public class ListChildProjectsIT extends AbstractDaemonTest {
   @Inject private ProjectOperations projectOperations;
+  @Inject private GroupOperations groupOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   @Test
   public void listChildrenOfNonExistingProject_NotFound() throws Exception {
@@ -84,4 +94,29 @@
         .containsExactly(child1_1, child1_1_1, child1_1_1_1, child1_2)
         .inOrder();
   }
+
+  @Test
+  public void listChildrenVisibility() throws Exception {
+    Project.NameKey parent = projectOperations.newProject().createEmptyCommit(true).create();
+    Project.NameKey project =
+        projectOperations.newProject().createEmptyCommit(true).parent(parent).create();
+
+    AccountGroup.UUID privilegedGroupUuid =
+        groupOperations.newGroup().name(name("privilegedGroup")).create();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(privilegedGroupUuid))
+        .add(block(Permission.READ).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+        .update();
+
+    TestAccount privilegedUser =
+        accountCreator.create("privilegedUser", "snowden@nsa.gov", "Ed Snowden", null);
+    groupOperations.group(privilegedGroupUuid).forUpdate().addMember(privilegedUser.id()).update();
+
+    requestScopeOperations.setApiUser(user.id());
+    assertThat(gApi.projects().name(parent.get()).children(false)).isEmpty();
+    requestScopeOperations.setApiUser(privilegedUser.id());
+    assertThat(gApi.projects().name(parent.get()).children(false)).isNotEmpty();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
index d39c96e..a397693 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
@@ -65,7 +66,7 @@
   @Test
   public void allProjectsLabels() throws Exception {
     List<LabelDefinitionInfo> labels = gApi.projects().name(allProjects.get()).labels().get();
-    assertThat(labelNames(labels)).containsExactly("Code-Review");
+    assertThat(labelNames(labels)).containsExactly(LabelId.CODE_REVIEW);
 
     LabelDefinitionInfo codeReviewLabel = Iterables.getOnlyElement(labels);
     LabelAssert.assertCodeReviewLabel(codeReviewLabel);
@@ -135,6 +136,7 @@
     assertThat(fooLabel.copyAnyScore).isNull();
     assertThat(fooLabel.copyMinScore).isNull();
     assertThat(fooLabel.copyMaxScore).isNull();
+    assertThat(fooLabel.copyAllScoresIfListOfFilesDidNotChange).isNull();
     assertThat(fooLabel.copyAllScoresIfNoChange).isNull();
     assertThat(fooLabel.copyAllScoresIfNoCodeChange).isNull();
     assertThat(fooLabel.copyAllScoresOnTrivialRebase).isNull();
@@ -157,6 +159,7 @@
                 labelType.setCopyAnyScore(true);
                 labelType.setCopyMinScore(true);
                 labelType.setCopyMaxScore(true);
+                labelType.setCopyAllScoresIfListOfFilesDidNotChange(true);
                 labelType.setCopyAllScoresIfNoCodeChange(true);
                 labelType.setCopyAllScoresOnTrivialRebase(true);
                 labelType.setCopyAllScoresOnMergeFirstParentUpdate(true);
@@ -174,6 +177,7 @@
     assertThat(fooLabel.copyAnyScore).isTrue();
     assertThat(fooLabel.copyMinScore).isTrue();
     assertThat(fooLabel.copyMaxScore).isTrue();
+    assertThat(fooLabel.copyAllScoresIfListOfFilesDidNotChange).isTrue();
     assertThat(fooLabel.copyAllScoresIfNoChange).isTrue();
     assertThat(fooLabel.copyAllScoresIfNoCodeChange).isTrue();
     assertThat(fooLabel.copyAllScoresOnTrivialRebase).isTrue();
@@ -210,7 +214,7 @@
   public void inheritedLabelsOnly() throws Exception {
     List<LabelDefinitionInfo> labels =
         gApi.projects().name(project.get()).labels().withInherited(true).get();
-    assertThat(labelNames(labels)).containsExactly("Code-Review");
+    assertThat(labelNames(labels)).containsExactly(LabelId.CODE_REVIEW);
 
     LabelDefinitionInfo codeReviewLabel = Iterables.getOnlyElement(labels);
     LabelAssert.assertCodeReviewLabel(codeReviewLabel);
@@ -224,7 +228,9 @@
 
     List<LabelDefinitionInfo> labels =
         gApi.projects().name(project.get()).labels().withInherited(true).get();
-    assertThat(labelNames(labels)).containsExactly("Code-Review", "bar", "baz", "foo").inOrder();
+    assertThat(labelNames(labels))
+        .containsExactly(LabelId.CODE_REVIEW, "bar", "baz", "foo")
+        .inOrder();
 
     LabelAssert.assertCodeReviewLabel(labels.get(0));
     assertThat(labels.get(1).name).isEqualTo("bar");
@@ -237,14 +243,14 @@
 
   @Test
   public void withInheritedLabelsAndOverriddenLabel() throws Exception {
-    configLabel("Code-Review", LabelFunction.NO_OP);
+    configLabel(LabelId.CODE_REVIEW, LabelFunction.NO_OP);
 
     List<LabelDefinitionInfo> labels =
         gApi.projects().name(project.get()).labels().withInherited(true).get();
-    assertThat(labelNames(labels)).containsExactly("Code-Review", "Code-Review");
+    assertThat(labelNames(labels)).containsExactly(LabelId.CODE_REVIEW, LabelId.CODE_REVIEW);
 
     LabelAssert.assertCodeReviewLabel(labels.get(0));
-    assertThat(labels.get(1).name).isEqualTo("Code-Review");
+    assertThat(labels.get(1).name).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(labels.get(1).projectName).isEqualTo(project.get());
     assertThat(labels.get(1).function).isEqualTo(LabelFunction.NO_OP.getFunctionName());
   }
@@ -259,7 +265,7 @@
 
     List<LabelDefinitionInfo> labels =
         gApi.projects().name(childProject.get()).labels().withInherited(true).get();
-    assertThat(labelNames(labels)).containsExactly("Code-Review", "foo", "bar").inOrder();
+    assertThat(labelNames(labels)).containsExactly(LabelId.CODE_REVIEW, "foo", "bar").inOrder();
 
     LabelAssert.assertCodeReviewLabel(labels.get(0));
     assertThat(labels.get(1).name).isEqualTo("foo");
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/PostLabelsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/PostLabelsIT.java
index ba52024..cbaba2e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/PostLabelsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/PostLabelsIT.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.common.BatchLabelInput;
@@ -124,7 +125,7 @@
   @Test
   public void cannotCreateLabelWithNameThatIsAlreadyInUse() throws Exception {
     LabelDefinitionInput labelInput = new LabelDefinitionInput();
-    labelInput.name = "Code-Review";
+    labelInput.name = LabelId.CODE_REVIEW;
     BatchLabelInput input = new BatchLabelInput();
     input.create = ImmutableList.of(labelInput);
 
@@ -319,7 +320,7 @@
     labelInput.commitMessage = "Update label";
 
     BatchLabelInput input = new BatchLabelInput();
-    input.update = ImmutableMap.of("Code-Review", labelInput);
+    input.update = ImmutableMap.of(LabelId.CODE_REVIEW, labelInput);
 
     BadRequestException thrown =
         assertThrows(
@@ -425,7 +426,7 @@
   @Test
   public void defaultCommitMessage() throws Exception {
     BatchLabelInput input = new BatchLabelInput();
-    input.delete = ImmutableList.of("Code-Review");
+    input.delete = ImmutableList.of(LabelId.CODE_REVIEW);
     gApi.projects().name(allProjects.get()).labels(input);
     assertThat(
             projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
@@ -436,7 +437,7 @@
   public void withCommitMessage() throws Exception {
     BatchLabelInput input = new BatchLabelInput();
     input.commitMessage = "Batch Update Labels";
-    input.delete = ImmutableList.of("Code-Review");
+    input.delete = ImmutableList.of(LabelId.CODE_REVIEW);
     gApi.projects().name(allProjects.get()).labels(input);
     assertThat(
             projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
@@ -447,7 +448,7 @@
   public void commitMessageIsTrimmed() throws Exception {
     BatchLabelInput input = new BatchLabelInput();
     input.commitMessage = " Batch Update Labels ";
-    input.delete = ImmutableList.of("Code-Review");
+    input.delete = ImmutableList.of(LabelId.CODE_REVIEW);
     gApi.projects().name(allProjects.get()).labels(input);
     assertThat(
             projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
index 1e8d978..2e68b54 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
@@ -52,7 +53,7 @@
             () ->
                 gApi.projects()
                     .name(allProjects.get())
-                    .label("Code-Review")
+                    .label(LabelId.CODE_REVIEW)
                     .update(new LabelDefinitionInput()));
     assertThat(thrown).hasMessageThat().contains("Authentication required");
   }
@@ -72,7 +73,7 @@
             () ->
                 gApi.projects()
                     .name(allProjects.get())
-                    .label("Code-Review")
+                    .label(LabelId.CODE_REVIEW)
                     .update(new LabelDefinitionInput()));
     assertThat(thrown).hasMessageThat().contains("write refs/meta/config not permitted");
   }
@@ -83,13 +84,13 @@
     input.name = "Foo-Review";
 
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.name).isEqualTo(input.name);
 
     assertThat(gApi.projects().name(allProjects.get()).label("Foo-Review").get()).isNotNull();
     assertThrows(
         ResourceNotFoundException.class,
-        () -> gApi.projects().name(allProjects.get()).label("Code-Review").get());
+        () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get());
   }
 
   @Test
@@ -98,13 +99,13 @@
     input.name = " Foo-Review ";
 
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.name).isEqualTo("Foo-Review");
 
     assertThat(gApi.projects().name(allProjects.get()).label("Foo-Review").get()).isNotNull();
     assertThrows(
         ResourceNotFoundException.class,
-        () -> gApi.projects().name(allProjects.get()).label("Code-Review").get());
+        () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get());
   }
 
   @Test
@@ -115,7 +116,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input));
     assertThat(thrown).hasMessageThat().contains("name cannot be empty");
   }
 
@@ -127,7 +128,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input));
     assertThat(thrown).hasMessageThat().contains("invalid name: " + input.name);
   }
 
@@ -169,10 +170,10 @@
     input.function = LabelFunction.NO_OP.getFunctionName();
 
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.function).isEqualTo(input.function);
 
-    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().function)
+    assertThat(gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get().function)
         .isEqualTo(input.function);
   }
 
@@ -182,10 +183,10 @@
     input.function = " " + LabelFunction.NO_OP.getFunctionName() + " ";
 
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.function).isEqualTo(LabelFunction.NO_OP.getFunctionName());
 
-    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().function)
+    assertThat(gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get().function)
         .isEqualTo(LabelFunction.NO_OP.getFunctionName());
   }
 
@@ -197,7 +198,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input));
     assertThat(thrown).hasMessageThat().contains("function cannot be empty");
   }
 
@@ -209,7 +210,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input));
     assertThat(thrown).hasMessageThat().contains("unknown function: " + input.function);
   }
 
@@ -221,7 +222,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input));
     assertThat(thrown).hasMessageThat().contains("values cannot be empty");
   }
 
@@ -243,7 +244,7 @@
             "Looks Very Bad");
 
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.values)
         .containsExactly(
             "+2", "Looks Very Good",
@@ -252,7 +253,7 @@
             "-1", "Looks Bad",
             "-2", "Looks Very Bad");
 
-    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().values)
+    assertThat(gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get().values)
         .containsExactly(
             "+2", "Looks Very Good",
             "+1", "Looks Good",
@@ -279,7 +280,7 @@
             " Looks Very Bad ");
 
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.values)
         .containsExactly(
             "+2", "Looks Very Good",
@@ -288,7 +289,7 @@
             "-1", "Looks Bad",
             "-2", "Looks Very Bad");
 
-    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().values)
+    assertThat(gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get().values)
         .containsExactly(
             "+2", "Looks Very Good",
             "+1", "Looks Good",
@@ -305,7 +306,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input));
     assertThat(thrown).hasMessageThat().contains("invalid value: invalidValue");
   }
 
@@ -317,7 +318,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input));
     assertThat(thrown).hasMessageThat().contains("description for value '+1' cannot be empty");
   }
 
@@ -332,7 +333,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input));
     assertThat(thrown).hasMessageThat().contains("duplicate value: 1");
   }
 
@@ -342,10 +343,11 @@
     input.defaultValue = 1;
 
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.defaultValue).isEqualTo(input.defaultValue);
 
-    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().defaultValue)
+    assertThat(
+            gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get().defaultValue)
         .isEqualTo(input.defaultValue);
   }
 
@@ -357,7 +359,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input));
     assertThat(thrown).hasMessageThat().contains("invalid default value: " + input.defaultValue);
   }
 
@@ -369,10 +371,10 @@
         ImmutableList.of("refs/heads/master", "refs/heads/foo/*", "^refs/heads/stable-.*");
 
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.branches).containsExactlyElementsIn(input.branches);
 
-    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().branches)
+    assertThat(gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get().branches)
         .containsExactlyElementsIn(input.branches);
   }
 
@@ -383,11 +385,11 @@
         ImmutableList.of(" refs/heads/master ", " refs/heads/foo/* ", " ^refs/heads/stable-.* ");
 
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.branches)
         .containsExactly("refs/heads/master", "refs/heads/foo/*", "^refs/heads/stable-.*");
 
-    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().branches)
+    assertThat(gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get().branches)
         .containsExactly("refs/heads/master", "refs/heads/foo/*", "^refs/heads/stable-.*");
   }
 
@@ -397,10 +399,10 @@
     input.branches = ImmutableList.of("refs/heads/master", "", " ");
 
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.branches).containsExactly("refs/heads/master");
 
-    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().branches)
+    assertThat(gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get().branches)
         .containsExactly("refs/heads/master");
   }
 
@@ -408,15 +410,15 @@
   public void branchesCanBeUnset() throws Exception {
     LabelDefinitionInput input = new LabelDefinitionInput();
     input.branches = ImmutableList.of("refs/heads/master");
-    gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
-    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().branches)
+    gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
+    assertThat(gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get().branches)
         .isNotNull();
 
     input.branches = ImmutableList.of();
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.branches).isNull();
-    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().branches)
+    assertThat(gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get().branches)
         .isNull();
   }
 
@@ -428,7 +430,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input));
     assertThat(thrown).hasMessageThat().contains("invalid branch: refs heads master");
   }
 
@@ -438,10 +440,10 @@
     input.branches = ImmutableList.of("master", "refs/meta/config");
 
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.branches).containsExactly("refs/heads/master", "refs/meta/config");
 
-    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().branches)
+    assertThat(gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get().branches)
         .containsExactly("refs/heads/master", "refs/meta/config");
   }
 
@@ -582,6 +584,65 @@
   }
 
   @Test
+  public void setCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(
+            gApi.projects()
+                .name(project.get())
+                .label("foo")
+                .get()
+                .copyAllScoresIfListOfFilesDidNotChange)
+        .isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyAllScoresIfListOfFilesDidNotChange = true;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyAllScoresIfListOfFilesDidNotChange).isTrue();
+
+    assertThat(
+            gApi.projects()
+                .name(project.get())
+                .label("foo")
+                .get()
+                .copyAllScoresIfListOfFilesDidNotChange)
+        .isTrue();
+  }
+
+  @Test
+  public void unsetCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType("foo", lt -> lt.setCopyAllScoresIfListOfFilesDidNotChange(true));
+      u.save();
+    }
+    assertThat(
+            gApi.projects()
+                .name(project.get())
+                .label("foo")
+                .get()
+                .copyAllScoresIfListOfFilesDidNotChange)
+        .isTrue();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyAllScoresIfListOfFilesDidNotChange = false;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyAllScoresIfListOfFilesDidNotChange).isNull();
+
+    assertThat(
+            gApi.projects()
+                .name(project.get())
+                .label("foo")
+                .get()
+                .copyAllScoresIfListOfFilesDidNotChange)
+        .isNull();
+  }
+
+  @Test
   public void setCopyAllScoresIfNoChange() throws Exception {
     configLabel("foo", LabelFunction.NO_OP);
     try (ProjectConfigUpdate u = updateProject(project)) {
@@ -866,12 +927,12 @@
     LabelDefinitionInfo updatedLabel =
         gApi.projects()
             .name(allProjects.get())
-            .label("Code-Review")
+            .label(LabelId.CODE_REVIEW)
             .update(new LabelDefinitionInput());
     LabelAssert.assertCodeReviewLabel(updatedLabel);
 
     LabelAssert.assertCodeReviewLabel(
-        gApi.projects().name(allProjects.get()).label("Code-Review").get());
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get());
 
     assertThat(projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG))
         .isEqualTo(refsMetaConfigHead);
@@ -881,7 +942,7 @@
   public void defaultCommitMessage() throws Exception {
     LabelDefinitionInput input = new LabelDefinitionInput();
     input.function = LabelFunction.NO_OP.getFunctionName();
-    gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(
             projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
         .isEqualTo("Update label");
@@ -892,7 +953,7 @@
     LabelDefinitionInput input = new LabelDefinitionInput();
     input.function = LabelFunction.NO_OP.getFunctionName();
     input.commitMessage = "Set NoOp function";
-    gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(
             projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
         .isEqualTo(input.commitMessage);
@@ -903,7 +964,7 @@
     LabelDefinitionInput input = new LabelDefinitionInput();
     input.function = LabelFunction.NO_OP.getFunctionName();
     input.commitMessage = " Set NoOp function ";
-    gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(
             projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
         .isEqualTo("Set NoOp function");
diff --git a/javatests/com/google/gerrit/acceptance/server/account/ServiceUserClassifierIT.java b/javatests/com/google/gerrit/acceptance/server/account/ServiceUserClassifierIT.java
index 1e33c69..df84fd7 100644
--- a/javatests/com/google/gerrit/acceptance/server/account/ServiceUserClassifierIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/account/ServiceUserClassifierIT.java
@@ -44,7 +44,7 @@
 
   @Test
   public void userWithDirectMembershipInServiceUserIsAServiceUser() throws Exception {
-    TestAccount user = accountCreator.create(null, "Service Users");
+    TestAccount user = accountCreator.create(null, ServiceUserClassifier.SERVICE_USERS);
     assertThat(serviceUserClassifier.isServiceUser(user.id())).isTrue();
   }
 
@@ -91,7 +91,7 @@
 
   private AccountGroup.UUID serviceUsersUUID() {
     return groupCache
-        .get(AccountGroup.nameKey("Service Users"))
+        .get(AccountGroup.nameKey(ServiceUserClassifier.SERVICE_USERS))
         .orElseThrow(() -> new IllegalStateException("unable to find 'Service Users'"))
         .getGroupUUID();
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/BUILD b/javatests/com/google/gerrit/acceptance/server/change/BUILD
index 500ab06..19ca946 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/BUILD
+++ b/javatests/com/google/gerrit/acceptance/server/change/BUILD
@@ -5,7 +5,19 @@
     group = "server_change",
     labels = ["server"],
     deps = [
+        ":util",
         "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/util/time",
     ],
 )
+
+java_library(
+    name = "util",
+    srcs = ["CommentsUtil.java"],
+    deps = [
+        "//java/com/google/gerrit/acceptance:lib",
+        "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/extensions:api",
+        "@guava//jar",
+    ],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentContextIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentContextIT.java
new file mode 100644
index 0000000..548e3fe
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentContextIT.java
@@ -0,0 +1,387 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
+import static com.google.gerrit.entities.Patch.COMMIT_MSG;
+import static com.google.gerrit.entities.Patch.MERGE_LIST;
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.MoreCollectors;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.client.Comment;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.ContextLineInfo;
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class CommentContextIT extends AbstractDaemonTest {
+  /** The commit message of a single commit. */
+  private static final String SUBJECT =
+      String.join(
+          "\n",
+          "Commit Header",
+          "",
+          "This commit is doing something extremely important",
+          "",
+          "Footer: value");
+
+  private static final String FILE_CONTENT =
+      String.join("\n", "Line 1 of file", "", "Line 3 of file", "", "", "Line 6 of file");
+
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Before
+  public void setUp() {
+    requestScopeOperations.setApiUser(user.id());
+  }
+
+  @Test
+  public void commentContextForCommitMessageForLineComment() throws Exception {
+    PushOneCommit.Result result =
+        createChange(testRepo, "master", SUBJECT, FILE_NAME, FILE_CONTENT, "topic");
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput comment = CommentsUtil.newComment(COMMIT_MSG, Side.REVISION, 7, "comment", false);
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
+
+    List<CommentInfo> comments =
+        gApi.changes().id(changeId).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(1);
+
+    // The first few lines of the commit message are the headers, e.g.
+    // Parent: ...
+    // Author: ...
+    // AuthorDate: ...
+    // etc...
+    assertThat(comments.get(0).contextLines)
+        .containsExactlyElementsIn(createContextLines("7", "Commit Header"));
+  }
+
+  @Test
+  public void commentContextForMergeList() throws Exception {
+    PushOneCommit.Result result = createMergeCommitChange("refs/for/master");
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput comment = CommentsUtil.newComment(MERGE_LIST, Side.REVISION, 1, "comment", false);
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
+
+    List<CommentInfo> comments =
+        gApi.changes().id(changeId).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(1);
+    assertThat(comments.get(0).contextLines)
+        .containsExactlyElementsIn(createContextLines("1", "Merge List:"));
+  }
+
+  @Test
+  public void commentContextForCommitMessageForRangeComment() throws Exception {
+    PushOneCommit.Result result =
+        createChange(testRepo, "master", SUBJECT, FILE_NAME, FILE_CONTENT, "topic");
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput comment =
+        CommentsUtil.newComment(
+            COMMIT_MSG, Side.REVISION, createCommentRange(7, 9), "comment", false);
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
+
+    List<CommentInfo> comments =
+        gApi.changes().id(changeId).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(1);
+
+    // The first few lines of the commit message are the headers, e.g.
+    // Parent: ...
+    // Author: ...
+    // AuthorDate: ...
+    // etc...
+    assertThat(comments.get(0).contextLines)
+        .containsExactlyElementsIn(
+            createContextLines(
+                "7",
+                "Commit Header",
+                "8",
+                "",
+                "9",
+                "This commit is doing something extremely important"));
+  }
+
+  @Test
+  public void commentContextForCommitMessageInvalidLine() throws Exception {
+    PushOneCommit.Result result =
+        createChange(testRepo, "master", SUBJECT, FILE_NAME, FILE_CONTENT, "topic");
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput comment =
+        CommentsUtil.newComment(COMMIT_MSG, Side.REVISION, 100, "comment", false);
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
+
+    List<CommentInfo> comments =
+        gApi.changes().id(changeId).commentsRequest().withContext(true).getAsList();
+    assertThat(comments).hasSize(1);
+    assertThat(comments.get(0).contextLines).isEmpty();
+  }
+
+  @Test
+  public void listChangeCommentsWithContextEnabled() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+
+    ImmutableList.Builder<String> content = ImmutableList.builder();
+    for (int i = 1; i <= 10; i++) {
+      content.add("line_" + i);
+    }
+
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                PushOneCommit.SUBJECT,
+                FILE_NAME,
+                content.build().stream().collect(Collectors.joining("\n")),
+                r1.getChangeId())
+            .to("refs/for/master");
+
+    CommentsUtil.addCommentOnLine(gApi, r2, "nit: please fix", 1);
+    CommentsUtil.addCommentOnRange(gApi, r2, "looks good", createCommentRange(2, 5));
+
+    List<CommentInfo> comments =
+        gApi.changes().id(r2.getChangeId()).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(2);
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("nit: please fix"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(createContextLines("1", "line_1"));
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("looks good"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(
+            createContextLines("2", "line_2", "3", "line_3", "4", "line_4", "5", "line_5"));
+  }
+
+  @Test
+  public void commentContextForCommentsOnDifferentPatchsets() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+
+    ImmutableList.Builder<String> content = ImmutableList.builder();
+    for (int i = 1; i <= 10; i++) {
+      content.add("line_" + i);
+    }
+
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                PushOneCommit.SUBJECT,
+                FILE_NAME,
+                String.join("\n", content.build()),
+                r1.getChangeId())
+            .to("refs/for/master");
+
+    PushOneCommit.Result r3 =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                PushOneCommit.SUBJECT,
+                FILE_NAME,
+                content.build().stream().collect(Collectors.joining("\n")),
+                r1.getChangeId())
+            .to("refs/for/master");
+
+    CommentsUtil.addCommentOnLine(gApi, r2, "r2: please fix", 1);
+    CommentsUtil.addCommentOnRange(gApi, r2, "r2: looks good", createCommentRange(2, 3));
+    CommentsUtil.addCommentOnLine(gApi, r3, "r3: please fix", 6);
+    CommentsUtil.addCommentOnRange(gApi, r3, "r3: looks good", createCommentRange(7, 8));
+
+    List<CommentInfo> comments =
+        gApi.changes().id(r2.getChangeId()).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(4);
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("r2: please fix"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(createContextLines("1", "line_1"));
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("r2: looks good"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(createContextLines("2", "line_2", "3", "line_3"));
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("r3: please fix"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(createContextLines("6", "line_6"));
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("r3: looks good"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(createContextLines("7", "line_7", "8", "line_8"));
+  }
+
+  @Test
+  public void commentContextIsEmptyForPatchsetLevelComments() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput comment =
+        CommentsUtil.newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
+
+    List<CommentInfo> comments =
+        gApi.changes().id(changeId).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(1);
+    assertThat(comments.get(0).contextLines).isEmpty();
+  }
+
+  @Test
+  public void commentContextWithZeroPadding() throws Exception {
+    String changeId = createChangeWithComment(3, 4);
+    assertContextLines(changeId, /* contextPadding= */ 0, ImmutableList.of(3, 4));
+  }
+
+  @Test
+  public void commentContextWithSmallPadding() throws Exception {
+    String changeId = createChangeWithComment(3, 4);
+    assertContextLines(changeId, /* contextPadding= */ 1, ImmutableList.of(2, 3, 4, 5));
+  }
+
+  @Test
+  public void commentContextWithSmallPaddingAtTheBeginningOfFile() throws Exception {
+    String changeId = createChangeWithComment(1, 2);
+    assertContextLines(changeId, /* contextPadding= */ 2, ImmutableList.of(1, 2, 3, 4));
+  }
+
+  @Test
+  public void commentContextWithPaddingLargerThanFileSize() throws Exception {
+    String changeId = createChangeWithComment(3, 3);
+    assertContextLines(
+        changeId,
+        /* contextPadding= */ 20,
+        ImmutableList.of(1, 2, 3, 4, 5, 6)); // file only contains six lines.
+  }
+
+  @Test
+  public void commentContextWithLargePaddingReturnsAdjustedMaximumPadding() throws Exception {
+    String changeId = createChangeWithCommentLarge(250, 250);
+    assertContextLines(
+        changeId,
+        /* contextPadding= */ 300,
+        IntStream.range(200, 301).boxed().collect(ImmutableList.toImmutableList()));
+  }
+
+  private String createChangeWithComment(int startLine, int endLine) throws Exception {
+    PushOneCommit.Result result =
+        createChange(testRepo, "master", SUBJECT, FILE_NAME, FILE_CONTENT, "topic");
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    Comment.Range commentRange = createCommentRange(startLine, endLine);
+    CommentInput comment =
+        CommentsUtil.newComment(FILE_NAME, Side.REVISION, commentRange, "comment", false);
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
+    return changeId;
+  }
+
+  private String createChangeWithCommentLarge(int startLine, int endLine) throws Exception {
+    StringBuilder largeContent = new StringBuilder();
+    for (int i = 0; i < 1000; i++) {
+      largeContent.append("line " + i + "\n");
+    }
+    PushOneCommit.Result result =
+        createChange(testRepo, "master", SUBJECT, FILE_NAME, largeContent.toString(), "topic");
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    Comment.Range commentRange = createCommentRange(startLine, endLine);
+    CommentInput comment =
+        CommentsUtil.newComment(FILE_NAME, Side.REVISION, commentRange, "comment", false);
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
+    return changeId;
+  }
+
+  private void assertContextLines(
+      String changeId, int contextPadding, ImmutableList<Integer> expectedLines) throws Exception {
+    List<CommentInfo> comments =
+        gApi.changes()
+            .id(changeId)
+            .commentsRequest()
+            .withContext(true)
+            .contextPadding(contextPadding)
+            .getAsList();
+
+    assertThat(comments).hasSize(1);
+    assertThat(
+            comments.get(0).contextLines.stream()
+                .map(c -> c.lineNumber)
+                .collect(Collectors.toList()))
+        .containsExactlyElementsIn(expectedLines);
+  }
+
+  private Comment.Range createCommentRange(int startLine, int endLine) {
+    Comment.Range range = new Comment.Range();
+    range.startLine = startLine;
+    range.endLine = endLine;
+    return range;
+  }
+
+  private List<ContextLineInfo> createContextLines(String... args) {
+    List<ContextLineInfo> result = new ArrayList<>();
+    for (int i = 0; i < args.length; i += 2) {
+      int lineNbr = Integer.parseInt(args[i]);
+      String contextLine = args[i + 1];
+      ContextLineInfo info = new ContextLineInfo(lineNbr, contextLine);
+      result.add(info);
+    }
+    return result;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index b4dd4b3..b29c031 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -23,14 +23,11 @@
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.truth.MapSubject.assertThatMap;
-import static java.util.stream.Collectors.groupingBy;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
-import com.google.common.collect.MoreCollectors;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -54,7 +51,6 @@
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.common.ContextLineInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -72,7 +68,6 @@
 import com.google.inject.Provider;
 import java.sql.Timestamp;
 import java.util.ArrayList;
-import java.util.Arrays;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -80,7 +75,6 @@
 import java.util.function.Function;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
-import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -124,7 +118,7 @@
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       String path = "file1";
-      DraftInput comment = newDraft(path, Side.REVISION, line, "comment 1");
+      DraftInput comment = CommentsUtil.newDraft(path, Side.REVISION, line, "comment 1");
       addDraft(changeId, revId, comment);
       Map<String, List<CommentInfo>> result = getDraftComments(changeId, revId);
       assertThat(result).hasSize(1);
@@ -145,10 +139,10 @@
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       String path = "file1";
-      DraftInput c1 = newDraft(path, Side.REVISION, line, "ps-1");
-      DraftInput c2 = newDraft(path, Side.PARENT, line, "auto-merge of ps-1");
-      DraftInput c3 = newDraftOnParent(path, 1, line, "parent-1 of ps-1");
-      DraftInput c4 = newDraftOnParent(path, 2, line, "parent-2 of ps-1");
+      DraftInput c1 = CommentsUtil.newDraft(path, Side.REVISION, line, "ps-1");
+      DraftInput c2 = CommentsUtil.newDraft(path, Side.PARENT, line, "auto-merge of ps-1");
+      DraftInput c3 = CommentsUtil.newDraftOnParent(path, 1, line, "parent-1 of ps-1");
+      DraftInput c4 = CommentsUtil.newDraftOnParent(path, 2, line, "parent-2 of ps-1");
       addDraft(changeId, revId, c1);
       addDraft(changeId, revId, c2);
       addDraft(changeId, revId, c3);
@@ -174,7 +168,7 @@
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       ReviewInput input = new ReviewInput();
-      CommentInput comment = newComment(file, Side.REVISION, line, "comment 1", false);
+      CommentInput comment = CommentsUtil.newComment(file, Side.REVISION, line, "comment 1", false);
       input.comments = new HashMap<>();
       input.comments.put(comment.path, Lists.newArrayList(comment));
       revision(r).review(input);
@@ -193,8 +187,9 @@
     String changeId = result.getChangeId();
     String ps1 = result.getCommit().name();
 
-    CommentInput comment = newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
-    addComments(changeId, ps1, comment);
+    CommentInput comment =
+        CommentsUtil.newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
 
     Map<String, List<CommentInfo>> results = getPublishedComments(changeId, ps1);
     assertThatMap(results).keys().containsExactly(PATCHSET_LEVEL);
@@ -207,8 +202,9 @@
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
     String commentMessage = "to be deleted";
-    CommentInput comment = newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, commentMessage);
-    addComments(changeId, revId, comment);
+    CommentInput comment =
+        CommentsUtil.newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, commentMessage);
+    CommentsUtil.addComments(gApi, changeId, revId, comment);
 
     Map<String, List<CommentInfo>> results = getPublishedComments(changeId, revId);
     CommentInfo oldComment = Iterables.getOnlyElement(results.get(PATCHSET_LEVEL));
@@ -227,8 +223,9 @@
     String changeId = result.getChangeId();
     String ps1 = result.getCommit().name();
 
-    CommentInput comment = newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
-    addComments(changeId, ps1, comment);
+    CommentInput comment =
+        CommentsUtil.newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
 
     String emailBody = Iterables.getOnlyElement(email.getMessages()).body();
     assertThat(emailBody).contains("Patchset");
@@ -241,10 +238,11 @@
     String changeId = result.getChangeId();
     String ps1 = result.getCommit().name();
 
-    CommentInput input = newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    CommentInput input = CommentsUtil.newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     input.line = 1;
     BadRequestException ex =
-        assertThrows(BadRequestException.class, () -> addComments(changeId, ps1, input));
+        assertThrows(
+            BadRequestException.class, () -> CommentsUtil.addComments(gApi, changeId, ps1, input));
     assertThat(ex.getMessage()).contains("line");
   }
 
@@ -254,10 +252,11 @@
     String changeId = result.getChangeId();
     String ps1 = result.getCommit().name();
 
-    CommentInput input = newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    CommentInput input = CommentsUtil.newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     input.range = createLineRange(1, 3);
     BadRequestException ex =
-        assertThrows(BadRequestException.class, () -> addComments(changeId, ps1, input));
+        assertThrows(
+            BadRequestException.class, () -> CommentsUtil.addComments(gApi, changeId, ps1, input));
     assertThat(ex.getMessage()).contains("range");
   }
 
@@ -267,10 +266,11 @@
     String changeId = result.getChangeId();
     String ps1 = result.getCommit().name();
 
-    CommentInput input = newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    CommentInput input = CommentsUtil.newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     input.side = Side.REVISION;
     BadRequestException ex =
-        assertThrows(BadRequestException.class, () -> addComments(changeId, ps1, input));
+        assertThrows(
+            BadRequestException.class, () -> CommentsUtil.addComments(gApi, changeId, ps1, input));
     assertThat(ex.getMessage()).contains("side");
   }
 
@@ -279,7 +279,7 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    DraftInput comment = CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     addDraft(changeId, revId, comment);
     Map<String, List<CommentInfo>> results = getDraftComments(changeId, revId);
     assertThatMap(results).keys().containsExactly(PATCHSET_LEVEL);
@@ -290,7 +290,7 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput draft = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment 1");
+    DraftInput draft = CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment 1");
     CommentInfo returned = addDraft(changeId, revId, draft);
     deleteDraft(changeId, revId, returned.id);
     Map<String, List<CommentInfo>> drafts = getDraftComments(changeId, revId);
@@ -302,7 +302,7 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    DraftInput comment = CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     comment.line = 1;
     BadRequestException ex =
         assertThrows(BadRequestException.class, () -> addDraft(changeId, revId, comment));
@@ -314,7 +314,7 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    DraftInput comment = CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     comment.range = createLineRange(1, 3);
     BadRequestException ex =
         assertThrows(BadRequestException.class, () -> addDraft(changeId, revId, comment));
@@ -326,7 +326,7 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    DraftInput comment = CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     comment.side = Side.REVISION;
     BadRequestException ex =
         assertThrows(BadRequestException.class, () -> addDraft(changeId, revId, comment));
@@ -338,10 +338,10 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    DraftInput comment = CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     addDraft(changeId, revId, comment);
     Map<String, List<CommentInfo>> results = getDraftComments(changeId, revId);
-    DraftInput update = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    DraftInput update = CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     update.id = Iterables.getOnlyElement(results.get(PATCHSET_LEVEL)).id;
     update.line = 1;
     BadRequestException ex =
@@ -355,10 +355,10 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    DraftInput comment = CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     addDraft(changeId, revId, comment);
     Map<String, List<CommentInfo>> results = getDraftComments(changeId, revId);
-    DraftInput update = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    DraftInput update = CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     update.id = Iterables.getOnlyElement(results.get(PATCHSET_LEVEL)).id;
     update.range = createLineRange(1, 3);
     BadRequestException ex =
@@ -372,10 +372,10 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    DraftInput comment = CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     addDraft(changeId, revId, comment);
     Map<String, List<CommentInfo>> results = getDraftComments(changeId, revId);
-    DraftInput update = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    DraftInput update = CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     update.id = Iterables.getOnlyElement(results.get(PATCHSET_LEVEL)).id;
     update.side = Side.REVISION;
     BadRequestException ex =
@@ -395,7 +395,7 @@
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       ReviewInput input = new ReviewInput();
-      CommentInput comment = newComment(file, Side.REVISION, line, "comment 1", false);
+      CommentInput comment = CommentsUtil.newComment(file, Side.REVISION, line, "comment 1", false);
       input.comments = new HashMap<>();
       input.comments.put(comment.path, Lists.newArrayList(comment));
       revision(r).review(input);
@@ -403,7 +403,7 @@
       CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
 
       input = new ReviewInput();
-      comment = newComment(file, Side.REVISION, line, "comment 1 reply", false);
+      comment = CommentsUtil.newComment(file, Side.REVISION, line, "comment 1 reply", false);
       comment.inReplyTo = actual.id;
       input.comments = new HashMap<>();
       input.comments.put(comment.path, Lists.newArrayList(comment));
@@ -427,7 +427,7 @@
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       ReviewInput input = new ReviewInput();
-      CommentInput comment = newComment(file, Side.REVISION, line, "comment 1", true);
+      CommentInput comment = CommentsUtil.newComment(file, Side.REVISION, line, "comment 1", true);
       input.comments = new HashMap<>();
       input.comments.put(comment.path, Lists.newArrayList(comment));
       revision(r).review(input);
@@ -448,10 +448,11 @@
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       ReviewInput input = new ReviewInput();
-      CommentInput c1 = newComment(file, Side.REVISION, line, "ps-1", false);
-      CommentInput c2 = newComment(file, Side.PARENT, line, "auto-merge of ps-1", false);
-      CommentInput c3 = newCommentOnParent(file, 1, line, "parent-1 of ps-1");
-      CommentInput c4 = newCommentOnParent(file, 2, line, "parent-2 of ps-1");
+      CommentInput c1 = CommentsUtil.newComment(file, Side.REVISION, line, "ps-1", false);
+      CommentInput c2 =
+          CommentsUtil.newComment(file, Side.PARENT, line, "auto-merge of ps-1", false);
+      CommentInput c3 = CommentsUtil.newCommentOnParent(file, 1, line, "parent-1 of ps-1");
+      CommentInput c4 = CommentsUtil.newCommentOnParent(file, 2, line, "parent-2 of ps-1");
       input.comments = new HashMap<>();
       input.comments.put(file, ImmutableList.of(c1, c2, c3, c4));
       revision(r).review(input);
@@ -470,9 +471,9 @@
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       ReviewInput input = new ReviewInput();
-      CommentInput c1 = newComment(file, Side.REVISION, line, "ps-1", false);
-      CommentInput c2 = newCommentOnParent(file, 1, line, "parent-1 of ps-1");
-      CommentInput c3 = newCommentOnParent(file, 2, line, "parent-2 of ps-1");
+      CommentInput c1 = CommentsUtil.newComment(file, Side.REVISION, line, "ps-1", false);
+      CommentInput c2 = CommentsUtil.newCommentOnParent(file, 1, line, "parent-1 of ps-1");
+      CommentInput c3 = CommentsUtil.newCommentOnParent(file, 2, line, "parent-2 of ps-1");
       input.comments = new HashMap<>();
       input.comments.put(file, ImmutableList.of(c1, c2, c3));
       revision(r).review(input);
@@ -489,7 +490,8 @@
   public void postCommentOnCommitMessageOnAutoMerge() throws Exception {
     PushOneCommit.Result r = createMergeCommitChange("refs/for/master");
     ReviewInput input = new ReviewInput();
-    CommentInput c = newComment(Patch.COMMIT_MSG, Side.PARENT, 0, "comment on auto-merge", false);
+    CommentInput c =
+        CommentsUtil.newComment(Patch.COMMIT_MSG, Side.PARENT, 0, "comment on auto-merge", false);
     input.comments = new HashMap<>();
     input.comments.put(Patch.COMMIT_MSG, ImmutableList.of(c));
     BadRequestException thrown =
@@ -508,7 +510,7 @@
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
 
-    DraftInput draft = newDraft(file, Side.REVISION, 0, "comment");
+    DraftInput draft = CommentsUtil.newDraft(file, Side.REVISION, 0, "comment");
     addDraft(changeId, revId, draft);
     Map<String, List<CommentInfo>> drafts = getDraftComments(changeId, revId);
     CommentInfo draftInfo = Iterables.getOnlyElement(drafts.get(draft.path));
@@ -516,7 +518,7 @@
     ReviewInput reviewInput = new ReviewInput();
     reviewInput.drafts = DraftHandling.KEEP;
     reviewInput.message = "foo";
-    CommentInput comment = newComment(file, Side.REVISION, 0, "comment", false);
+    CommentInput comment = CommentsUtil.newComment(file, Side.REVISION, 0, "comment", false);
     // Replace the existing draft.
     comment.id = draftInfo.id;
     reviewInput.comments = new HashMap<>();
@@ -545,14 +547,14 @@
 
     String draftRefName = RefNames.refsDraftComments(r1.getChange().getId(), admin.id());
 
-    DraftInput draft = newDraft(file, Side.REVISION, 1, "comment");
+    DraftInput draft = CommentsUtil.newDraft(file, Side.REVISION, 1, "comment");
     addDraft(changeId, "1", draft);
     ReviewInput reviewInput = new ReviewInput();
     reviewInput.drafts = DraftHandling.PUBLISH;
     reviewInput.message = "foo";
     gApi.changes().id(r1.getChangeId()).revision(1).review(reviewInput);
 
-    addDraft(changeId, "2", newDraft(file, Side.REVISION, 2, "comment2"));
+    addDraft(changeId, "2", CommentsUtil.newDraft(file, Side.REVISION, 2, "comment2"));
     reviewInput = new ReviewInput();
     reviewInput.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
     reviewInput.message = "bar";
@@ -581,7 +583,8 @@
     List<CommentInput> expectedComments = new ArrayList<>();
     for (Integer line : lines) {
       ReviewInput input = new ReviewInput();
-      CommentInput comment = newComment(file, Side.REVISION, line, "comment " + line, false);
+      CommentInput comment =
+          CommentsUtil.newComment(file, Side.REVISION, line, "comment " + line, false);
       expectedComments.add(comment);
       input.comments = new HashMap<>();
       input.comments.put(comment.path, Lists.newArrayList(comment));
@@ -610,11 +613,11 @@
     String revId = r.getCommit().getName();
     String draftRefName = RefNames.refsDraftComments(r.getChange().getId(), user.id());
 
-    DraftInput comment1 = newDraft("file_1", Side.REVISION, 1, "comment 1");
+    DraftInput comment1 = CommentsUtil.newDraft("file_1", Side.REVISION, 1, "comment 1");
     CommentInfo commentInfo1 = addDraft(changeId, revId, comment1);
     assertThat(getHeadOfDraftCommentsRef(draftRefName).getParentCount()).isEqualTo(0);
 
-    DraftInput comment2 = newDraft("file_2", Side.REVISION, 2, "comment 2");
+    DraftInput comment2 = CommentsUtil.newDraft("file_2", Side.REVISION, 2, "comment 2");
     CommentInfo commentInfo2 = addDraft(changeId, revId, comment2);
     assertThat(getHeadOfDraftCommentsRef(draftRefName).getParentCount()).isEqualTo(0);
 
@@ -639,7 +642,7 @@
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       String path = "file1";
-      DraftInput comment = newDraft(path, Side.REVISION, line, "comment 1");
+      DraftInput comment = CommentsUtil.newDraft(path, Side.REVISION, line, "comment 1");
       addDraft(changeId, revId, comment);
       Map<String, List<CommentInfo>> result = getDraftComments(changeId, revId);
       CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
@@ -662,7 +665,7 @@
     String parentCommentUuid =
         changeOperations.change(changeId).currentPatchset().newComment().create();
 
-    DraftInput draft = newDraft(COMMIT_MSG, Side.REVISION, 0, "foo");
+    DraftInput draft = CommentsUtil.newDraft(COMMIT_MSG, Side.REVISION, 0, "foo");
     draft.inReplyTo = parentCommentUuid;
     String createdDraftUuid = addDraft(changeId, draft).id;
     TestHumanComment actual =
@@ -676,7 +679,7 @@
     String parentRobotCommentUuid =
         changeOperations.change(changeId).currentPatchset().newRobotComment().create();
 
-    DraftInput draft = newDraft(COMMIT_MSG, Side.REVISION, 0, "foo");
+    DraftInput draft = CommentsUtil.newDraft(COMMIT_MSG, Side.REVISION, 0, "foo");
     draft.inReplyTo = parentRobotCommentUuid;
     String createdDraftUuid = addDraft(changeId, draft).id;
     TestHumanComment actual =
@@ -690,9 +693,9 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput comment = newDraft(file, Side.REVISION, 0, "foo");
+    DraftInput comment = CommentsUtil.newDraft(file, Side.REVISION, 0, "foo");
     CommentInfo commentInfo = addDraft(changeId, revId, comment);
-    DraftInput draftInput = newDraft(file, Side.REVISION, 0, "bar");
+    DraftInput draftInput = CommentsUtil.newDraft(file, Side.REVISION, 0, "bar");
     draftInput.id = "anything_but_" + commentInfo.id;
     BadRequestException e =
         assertThrows(
@@ -707,7 +710,7 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput comment = newDraft(file, Side.REVISION, -666, "foo");
+    DraftInput comment = CommentsUtil.newDraft(file, Side.REVISION, -666, "foo");
     BadRequestException e =
         assertThrows(BadRequestException.class, () -> addDraft(changeId, revId, comment));
     assertThat(e).hasMessageThat().contains("line must be >= 0");
@@ -719,7 +722,8 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput draftInput = newDraft(file, Side.REVISION, createLineRange(2, 3), "bar");
+    DraftInput draftInput =
+        CommentsUtil.newDraft(file, Side.REVISION, createLineRange(2, 3), "bar");
     draftInput.line = 666;
     BadRequestException e =
         assertThrows(BadRequestException.class, () -> addDraft(changeId, revId, draftInput));
@@ -731,7 +735,7 @@
   @Test
   public void putDraft_invalidInReplyTo() throws Exception {
     Change.Id changeId = changeOperations.newChange().create();
-    DraftInput draft = newDraft(COMMIT_MSG, Side.REVISION, 0, "foo");
+    DraftInput draft = CommentsUtil.newDraft(COMMIT_MSG, Side.REVISION, 0, "foo");
     draft.inReplyTo = "invalid";
     BadRequestException exception =
         assertThrows(BadRequestException.class, () -> addDraft(changeId, draft));
@@ -743,10 +747,10 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput comment = newDraft("file_foo", Side.REVISION, 0, "foo");
+    DraftInput comment = CommentsUtil.newDraft("file_foo", Side.REVISION, 0, "foo");
     CommentInfo commentInfo = addDraft(changeId, revId, comment);
     assertThat(getDraftComments(changeId, revId).keySet()).containsExactly("file_foo");
-    DraftInput draftInput = newDraft("file_bar", Side.REVISION, 0, "bar");
+    DraftInput draftInput = CommentsUtil.newDraft("file_bar", Side.REVISION, 0, "bar");
     updateDraft(changeId, revId, draftInput, commentInfo.id);
     assertThat(getDraftComments(changeId, revId).keySet()).containsExactly("file_bar");
   }
@@ -754,10 +758,10 @@
   @Test
   public void putDraft_updateInvalidInReplyTo() throws Exception {
     Change.Id changeId = changeOperations.newChange().create();
-    DraftInput originalDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "foo");
+    DraftInput originalDraftInput = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 0, "foo");
     CommentInfo originalDraft = addDraft(changeId, originalDraftInput);
 
-    DraftInput updatedDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "bar");
+    DraftInput updatedDraftInput = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 0, "bar");
     updatedDraftInput.inReplyTo = "invalid";
     BadRequestException exception =
         assertThrows(
@@ -771,10 +775,10 @@
     Change.Id changeId = changeOperations.newChange().create();
     String parentCommentUuid =
         changeOperations.change(changeId).currentPatchset().newComment().create();
-    DraftInput originalDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "foo");
+    DraftInput originalDraftInput = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 0, "foo");
     CommentInfo originalDraft = addDraft(changeId, originalDraftInput);
 
-    DraftInput updateDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "bar");
+    DraftInput updateDraftInput = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 0, "bar");
     updateDraftInput.inReplyTo = parentCommentUuid;
     updateDraft(changeId, updateDraftInput, originalDraft.id);
     assertThat(changeOperations.change(changeId).draftComment(originalDraft.id).get().parentUuid())
@@ -786,10 +790,10 @@
     Change.Id changeId = changeOperations.newChange().create();
     String parentRobotCommentUuid =
         changeOperations.change(changeId).currentPatchset().newRobotComment().create();
-    DraftInput originalDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "foo");
+    DraftInput originalDraftInput = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 0, "foo");
     CommentInfo originalDraft = addDraft(changeId, originalDraftInput);
 
-    DraftInput updateDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "bar");
+    DraftInput updateDraftInput = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 0, "bar");
     updateDraftInput.inReplyTo = parentRobotCommentUuid;
     updateDraft(changeId, updateDraftInput, originalDraft.id);
     assertThat(changeOperations.change(changeId).draftComment(originalDraft.id).get().parentUuid())
@@ -799,10 +803,10 @@
   @Test
   public void putDraft_updateTag() throws Exception {
     Change.Id changeId = changeOperations.newChange().create();
-    DraftInput originalDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "foo");
+    DraftInput originalDraftInput = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 0, "foo");
     CommentInfo originalDraft = addDraft(changeId, originalDraftInput);
 
-    DraftInput updateDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "bar");
+    DraftInput updateDraftInput = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 0, "bar");
     String tag = "täg";
     updateDraftInput.tag = tag;
     updateDraft(changeId, updateDraftInput, originalDraft.id);
@@ -828,7 +832,7 @@
 
     // Each user can only see their own drafts.
     requestScopeOperations.setApiUser(accountId);
-    DraftInput draftInput = newDraft(FILE_NAME, Side.REVISION, 0, "bar");
+    DraftInput draftInput = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 0, "bar");
     draftInput.message = "Another comment text.";
     gApi.changes()
         .id(changeId.get())
@@ -851,7 +855,7 @@
 
     List<DraftInput> expectedDrafts = new ArrayList<>();
     for (Integer line : lines) {
-      DraftInput comment = newDraft(file, Side.REVISION, line, "comment " + line);
+      DraftInput comment = CommentsUtil.newDraft(file, Side.REVISION, line, "comment " + line);
       expectedDrafts.add(comment);
       addDraft(changeId, revId, comment);
     }
@@ -870,7 +874,7 @@
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       String path = "file1";
-      DraftInput comment = newDraft(path, Side.REVISION, line, "comment 1");
+      DraftInput comment = CommentsUtil.newDraft(path, Side.REVISION, line, "comment 1");
       CommentInfo returned = addDraft(changeId, revId, comment);
       CommentInfo actual = getDraftComment(changeId, revId, returned.id);
       assertThat(comment).isEqualTo(infoToDraft(path).apply(actual));
@@ -884,7 +888,7 @@
       Timestamp origLastUpdated = r.getChange().change().getLastUpdatedOn();
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
-      DraftInput draft = newDraft("file1", Side.REVISION, line, "comment 1");
+      DraftInput draft = CommentsUtil.newDraft("file1", Side.REVISION, line, "comment 1");
       CommentInfo returned = addDraft(changeId, revId, draft);
       deleteDraft(changeId, revId, returned.id);
       Map<String, List<CommentInfo>> drafts = getDraftComments(changeId, revId);
@@ -909,7 +913,7 @@
       Timestamp origLastUpdated = r.getChange().change().getLastUpdatedOn();
 
       ReviewInput input = new ReviewInput();
-      CommentInput comment = newComment(file, Side.REVISION, line, "comment 1", false);
+      CommentInput comment = CommentsUtil.newComment(file, Side.REVISION, line, "comment 1", false);
       comment.updated = timestamp;
       input.comments = new HashMap<>();
       input.comments.put(comment.path, Lists.newArrayList(comment));
@@ -935,11 +939,11 @@
     PushOneCommit.Result r1 = createChange();
     String changeId = r1.getChangeId();
     String revId = r1.getCommit().getName();
-    addComment(r1, "nit: trailing whitespace");
-    addComment(r1, "nit: trailing whitespace");
+    CommentsUtil.addComment(gApi, r1, "nit: trailing whitespace");
+    CommentsUtil.addComment(gApi, r1, "nit: trailing whitespace");
     Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
     assertThat(result.get(FILE_NAME)).hasSize(2);
-    addComment(r1, "nit: trailing whitespace", true, false, null);
+    CommentsUtil.addComment(gApi, r1, "nit: trailing whitespace", true, false, null);
     result = getPublishedComments(changeId, revId);
     assertThat(result.get(FILE_NAME)).hasSize(2);
 
@@ -949,7 +953,7 @@
             .to("refs/for/master");
     changeId = r2.getChangeId();
     revId = r2.getCommit().getName();
-    addComment(r2, "nit: trailing whitespace", true, false, null);
+    CommentsUtil.addComment(gApi, r2, "nit: trailing whitespace", true, false, null);
     result = getPublishedComments(changeId, revId);
     assertThat(result.get(FILE_NAME)).hasSize(1);
   }
@@ -967,17 +971,17 @@
     addDraft(
         r1.getChangeId(),
         r1.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, 1, "nit: trailing whitespace"));
+        CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 1, "nit: trailing whitespace"));
     addDraft(
         r2.getChangeId(),
         r2.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, 1, "typo: content"));
+        CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 1, "typo: content"));
 
     requestScopeOperations.setApiUser(user.id());
     addDraft(
         r2.getChangeId(),
         r2.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, 1, "+1, please fix"));
+        CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 1, "+1, please fix"));
 
     requestScopeOperations.setApiUser(admin.id());
     Map<String, List<CommentInfo>> actual = gApi.changes().id(r1.getChangeId()).drafts();
@@ -1018,8 +1022,8 @@
             .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "new cntent", r1.getChangeId())
             .to("refs/for/master");
 
-    addComment(r1, "nit: trailing whitespace");
-    addComment(r2, "typo: content");
+    CommentsUtil.addComment(gApi, r1, "nit: trailing whitespace");
+    CommentsUtil.addComment(gApi, r2, "typo: content");
 
     Map<String, List<CommentInfo>> actual =
         gApi.changes().id(r2.getChangeId()).commentsRequest().get();
@@ -1047,61 +1051,6 @@
   }
 
   @Test
-  public void listChangeCommentsWithContextEnabled() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-
-    ImmutableList.Builder<String> content = ImmutableList.builder();
-    for (int i = 1; i <= 10; i++) {
-      content.add("line_" + i);
-    }
-
-    PushOneCommit.Result r2 =
-        pushFactory
-            .create(
-                admin.newIdent(),
-                testRepo,
-                SUBJECT,
-                FILE_NAME,
-                content.build().stream().collect(Collectors.joining("\n")),
-                r1.getChangeId())
-            .to("refs/for/master");
-
-    addCommentOnLine(r2, "nit: please fix", 1);
-    addCommentOnRange(r2, "looks good", commentRangeInLines(2, 5));
-
-    List<CommentInfo> comments =
-        gApi.changes().id(r2.getChangeId()).commentsRequest().withContext(true).getAsList();
-
-    assertThat(comments).hasSize(2);
-
-    assertThat(
-            comments.stream()
-                .filter(c -> c.message.equals("nit: please fix"))
-                .collect(MoreCollectors.onlyElement())
-                .contextLines)
-        .containsExactlyElementsIn(contextLines("1", "line_1"));
-
-    assertThat(
-            comments.stream()
-                .filter(c -> c.message.equals("looks good"))
-                .collect(MoreCollectors.onlyElement())
-                .contextLines)
-        .containsExactlyElementsIn(
-            contextLines("2", "line_2", "3", "line_3", "4", "line_4", "5", "line_5"));
-  }
-
-  private List<ContextLineInfo> contextLines(String... args) {
-    List<ContextLineInfo> result = new ArrayList<>();
-    for (int i = 0; i < args.length; i += 2) {
-      int lineNbr = Integer.parseInt(args[i]);
-      String contextLine = args[i + 1];
-      ContextLineInfo info = new ContextLineInfo(lineNbr, contextLine);
-      result.add(info);
-    }
-    return result;
-  }
-
-  @Test
   public void listChangeCommentsAnonymousDoesNotRequireAuth() throws Exception {
     PushOneCommit.Result r1 = createChange();
 
@@ -1110,8 +1059,8 @@
             .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "new cntent", r1.getChangeId())
             .to("refs/for/master");
 
-    addComment(r1, "nit: trailing whitespace");
-    addComment(r2, "typo: content");
+    CommentsUtil.addComment(gApi, r1, "nit: trailing whitespace");
+    CommentsUtil.addComment(gApi, r2, "typo: content");
 
     List<CommentInfo> comments = gApi.changes().id(r1.getChangeId()).commentsRequest().getAsList();
     assertThat(comments.stream().map(c -> c.message).collect(toList()))
@@ -1129,7 +1078,7 @@
       PushOneCommit.Result r = createChange();
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
-      DraftInput comment = newDraft("file1", Side.REVISION, line, "comment 1");
+      DraftInput comment = CommentsUtil.newDraft("file1", Side.REVISION, line, "comment 1");
       addDraft(changeId, revId, comment);
       assertThat(gApi.changes().query("change:" + changeId + " has:draft").get()).hasSize(1);
     }
@@ -1163,39 +1112,42 @@
     addDraft(
         r1.getChangeId(),
         r1.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, createLineRange(4, 10), "Is it that bad?"));
+        CommentsUtil.newDraft(FILE_NAME, Side.REVISION, createLineRange(4, 10), "Is it that bad?"));
     addDraft(
         r1.getChangeId(),
         r1.getCommit().getName(),
-        newDraft(FILE_NAME, Side.PARENT, createLineRange(0, 7), "what happened to this?"));
+        CommentsUtil.newDraft(
+            FILE_NAME, Side.PARENT, createLineRange(0, 7), "what happened to this?"));
     addDraft(
         r2.getChangeId(),
         r2.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, createLineRange(4, 15), "better now"));
+        CommentsUtil.newDraft(FILE_NAME, Side.REVISION, createLineRange(4, 15), "better now"));
     addDraft(
         r2.getChangeId(),
         r2.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, 2, "typo: content"));
+        CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 2, "typo: content"));
     addDraft(
         r2.getChangeId(),
         r2.getCommit().getName(),
-        newDraft(FILE_NAME, Side.PARENT, 1, "comment 1 on base"));
+        CommentsUtil.newDraft(FILE_NAME, Side.PARENT, 1, "comment 1 on base"));
     addDraft(
         r2.getChangeId(),
         r2.getCommit().getName(),
-        newDraft(FILE_NAME, Side.PARENT, 2, "comment 2 on base"));
+        CommentsUtil.newDraft(FILE_NAME, Side.PARENT, 2, "comment 2 on base"));
 
     PushOneCommit.Result other = createChange();
     // Drafts on other changes aren't returned.
     addDraft(
         other.getChangeId(),
         other.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, 1, "unrelated comment"));
+        CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 1, "unrelated comment"));
 
     requestScopeOperations.setApiUser(admin.id());
     // Drafts by other users aren't returned.
     addDraft(
-        r2.getChangeId(), r2.getCommit().getName(), newDraft(FILE_NAME, Side.REVISION, 2, "oops"));
+        r2.getChangeId(),
+        r2.getCommit().getName(),
+        CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 2, "oops"));
     requestScopeOperations.setApiUser(user.id());
 
     ReviewInput reviewInput = new ReviewInput();
@@ -1325,7 +1277,7 @@
     pub.line = 1;
     pub.message = "published comment";
     pub.path = FILE_NAME;
-    ReviewInput rin = newInput(pub);
+    ReviewInput rin = CommentsUtil.newInput(pub);
     rin.tag = "tag1";
     gApi.changes().id(r.getChangeId()).current().review(rin);
 
@@ -1349,7 +1301,7 @@
   public void draftCommentsWithTagPublishPatchset() throws Exception {
     PushOneCommit.Result result = createChange();
 
-    DraftInput draft = newDraft(FILE_NAME, Side.REVISION, 2, "draft");
+    DraftInput draft = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 2, "draft");
     draft.tag = "old_tag";
     addDraft(result.getChangeId(), result.getCommit().name(), draft);
 
@@ -1369,7 +1321,7 @@
   public void draftCommentsWithTagPublishAllRevisions() throws Exception {
     PushOneCommit.Result result = createChange();
 
-    DraftInput draft = newDraft(FILE_NAME, Side.REVISION, 2, "draft");
+    DraftInput draft = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 2, "draft");
     draft.tag = "old_tag";
     addDraft(result.getChangeId(), result.getCommit().name(), draft);
 
@@ -1395,30 +1347,32 @@
     // PS1 has three comments in three different threads, PS2 has one comment in one thread.
     PushOneCommit.Result result = createChange("change 1", FILE_NAME, "content 1");
     String changeId1 = result.getChangeId();
-    addComment(result, "comment 1", false, true, null);
-    addComment(result, "comment 2", false, null, null);
-    addComment(result, "comment 3", false, false, null);
+    CommentsUtil.addComment(gApi, result, "comment 1", false, true, null);
+    CommentsUtil.addComment(gApi, result, "comment 2", false, null, null);
+    CommentsUtil.addComment(gApi, result, "comment 3", false, false, null);
     PushOneCommit.Result result2 = amendChange(changeId1);
-    addComment(result2, "comment4", false, true, null);
+    CommentsUtil.addComment(gApi, result2, "comment4", false, true, null);
 
     // Change2 has two comments in one thread, the first is unresolved and the second is resolved.
     result = createChange("change 2", FILE_NAME, "content 2");
     String changeId2 = result.getChangeId();
-    addComment(result, "comment 1", false, true, null);
+    CommentsUtil.addComment(gApi, result, "comment 1", false, true, null);
     Map<String, List<CommentInfo>> comments =
         getPublishedComments(changeId2, result.getCommit().name());
     assertThat(comments).hasSize(1);
     assertThat(comments.get(FILE_NAME)).hasSize(1);
-    addComment(result, "comment 2", false, false, comments.get(FILE_NAME).get(0).id);
+    CommentsUtil.addComment(
+        gApi, result, "comment 2", false, false, comments.get(FILE_NAME).get(0).id);
 
     // Change3 has two comments in one thread, the first is resolved, the second is unresolved.
     result = createChange("change 3", FILE_NAME, "content 3");
     String changeId3 = result.getChangeId();
-    addComment(result, "comment 1", false, false, null);
+    CommentsUtil.addComment(gApi, result, "comment 1", false, false, null);
     comments = getPublishedComments(result.getChangeId(), result.getCommit().name());
     assertThat(comments).hasSize(1);
     assertThat(comments.get(FILE_NAME)).hasSize(1);
-    addComment(result, "comment 2", false, true, comments.get(FILE_NAME).get(0).id);
+    CommentsUtil.addComment(
+        gApi, result, "comment 2", false, true, comments.get(FILE_NAME).get(0).id);
 
     try (AutoCloseable ignored = disableNoteDb()) {
       ChangeInfo changeInfo1 = Iterables.getOnlyElement(query(changeId1));
@@ -1436,7 +1390,7 @@
   @Test
   public void deleteCommentCannotBeAppliedByUser() throws Exception {
     PushOneCommit.Result result = createChange();
-    CommentInput targetComment = addComment(result.getChangeId());
+    CommentInput targetComment = CommentsUtil.addComment(gApi, result.getChangeId());
 
     Map<String, List<CommentInfo>> commentsMap =
         getPublishedComments(result.getChangeId(), result.getCommit().name());
@@ -1467,29 +1421,29 @@
     String ps1 = result1.getCommit().name();
 
     // 2nd commit: Add (c1) to PS1.
-    CommentInput c1 = newComment("a.txt", "comment 1");
-    addComments(changeId, ps1, c1);
+    CommentInput c1 = CommentsUtil.newComment("a.txt", "comment 1");
+    CommentsUtil.addComments(gApi, changeId, ps1, c1);
 
     // 3rd commit: Add (c2, c3) to PS1.
-    CommentInput c2 = newComment("a.txt", "comment 2");
-    CommentInput c3 = newComment("a.txt", "comment 3");
-    addComments(changeId, ps1, c2, c3);
+    CommentInput c2 = CommentsUtil.newComment("a.txt", "comment 2");
+    CommentInput c3 = CommentsUtil.newComment("a.txt", "comment 3");
+    CommentsUtil.addComments(gApi, changeId, ps1, c2, c3);
 
     // 4th commit: Add (c4) to PS1.
-    CommentInput c4 = newComment("a.txt", "comment 4");
-    addComments(changeId, ps1, c4);
+    CommentInput c4 = CommentsUtil.newComment("a.txt", "comment 4");
+    CommentsUtil.addComments(gApi, changeId, ps1, c4);
 
     // 5th commit: Create PS2.
     PushOneCommit.Result result2 = amendChange(changeId, "refs/for/master", "b.txt", "b");
     String ps2 = result2.getCommit().name();
 
     // 6th commit: Add (c5) to PS1.
-    CommentInput c5 = newComment("a.txt", "comment 5");
-    addComments(changeId, ps1, c5);
+    CommentInput c5 = CommentsUtil.newComment("a.txt", "comment 5");
+    CommentsUtil.addComments(gApi, changeId, ps1, c5);
 
     // 7th commit: Add (c6) to PS2.
-    CommentInput c6 = newComment("b.txt", "comment 6");
-    addComments(changeId, ps2, c6);
+    CommentInput c6 = CommentsUtil.newComment("b.txt", "comment 6");
+    CommentsUtil.addComments(gApi, changeId, ps2, c6);
 
     // 8th commit: Create PS3.
     PushOneCommit.Result result3 = amendChange(changeId);
@@ -1500,13 +1454,13 @@
     String ps4 = result4.getCommit().name();
 
     // 10th commit: Add (c7, c8) to PS4.
-    CommentInput c7 = newComment("c.txt", "comment 7");
-    CommentInput c8 = newComment("b.txt", "comment 8");
-    addComments(changeId, ps4, c7, c8);
+    CommentInput c7 = CommentsUtil.newComment("c.txt", "comment 7");
+    CommentInput c8 = CommentsUtil.newComment("b.txt", "comment 8");
+    CommentsUtil.addComments(gApi, changeId, ps4, c7, c8);
 
     // 11th commit: Add (c9) to PS2.
-    CommentInput c9 = newCommentWithOnlyMandatoryFields("b.txt", "comment 9");
-    addComments(changeId, ps2, c9);
+    CommentInput c9 = CommentsUtil.newCommentWithOnlyMandatoryFields("b.txt", "comment 9");
+    CommentsUtil.addComments(gApi, changeId, ps2, c9);
 
     List<CommentInfo> commentsBeforeDelete = getChangeSortedComments(id.get());
     assertThat(commentsBeforeDelete).hasSize(9);
@@ -1549,14 +1503,14 @@
     }
 
     // Make sure that comments can still be added correctly.
-    CommentInput c10 = newComment("a.txt", "comment 10");
-    CommentInput c11 = newComment("b.txt", "comment 11");
-    CommentInput c12 = newComment("a.txt", "comment 12");
-    CommentInput c13 = newComment("c.txt", "comment 13");
-    addComments(changeId, ps1, c10);
-    addComments(changeId, ps2, c11);
-    addComments(changeId, ps3, c12);
-    addComments(changeId, ps4, c13);
+    CommentInput c10 = CommentsUtil.newComment("a.txt", "comment 10");
+    CommentInput c11 = CommentsUtil.newComment("b.txt", "comment 11");
+    CommentInput c12 = CommentsUtil.newComment("a.txt", "comment 12");
+    CommentInput c13 = CommentsUtil.newComment("c.txt", "comment 13");
+    CommentsUtil.addComments(gApi, changeId, ps1, c10);
+    CommentsUtil.addComments(gApi, changeId, ps2, c11);
+    CommentsUtil.addComments(gApi, changeId, ps3, c12);
+    CommentsUtil.addComments(gApi, changeId, ps4, c13);
 
     assertThat(getChangeSortedComments(id.get())).hasSize(13);
     assertThat(getRevisionComments(changeId, ps1)).hasSize(6);
@@ -1572,12 +1526,12 @@
     String changeId = result.getChangeId();
     String ps1 = result.getCommit().name();
 
-    CommentInput c1 = newComment(FILE_NAME, "comment 1");
-    CommentInput c2 = newComment(FILE_NAME, "comment 2");
-    CommentInput c3 = newComment(FILE_NAME, "comment 3");
-    addComments(changeId, ps1, c1);
-    addComments(changeId, ps1, c2);
-    addComments(changeId, ps1, c3);
+    CommentInput c1 = CommentsUtil.newComment(FILE_NAME, "comment 1");
+    CommentInput c2 = CommentsUtil.newComment(FILE_NAME, "comment 2");
+    CommentInput c3 = CommentsUtil.newComment(FILE_NAME, "comment 3");
+    CommentsUtil.addComments(gApi, changeId, ps1, c1);
+    CommentsUtil.addComments(gApi, changeId, ps1, c2);
+    CommentsUtil.addComments(gApi, changeId, ps1, c3);
 
     List<CommentInfo> commentsBeforeDelete = getChangeSortedComments(id.get());
     assertThat(commentsBeforeDelete).hasSize(3);
@@ -1614,10 +1568,10 @@
     String parentRobotCommentUuid =
         changeOperations.change(changeId).currentPatchset().newRobotComment().create();
 
-    CommentInput createdCommentInput = newComment(COMMIT_MSG, "comment reply");
+    CommentInput createdCommentInput = CommentsUtil.newComment(COMMIT_MSG, "comment reply");
     createdCommentInput.inReplyTo = parentRobotCommentUuid;
     createdCommentInput.unresolved = null;
-    addComments(changeId, createdCommentInput);
+    CommentsUtil.addComments(gApi, changeId, createdCommentInput);
 
     CommentInfo resultNewComment =
         Iterables.getOnlyElement(
@@ -1637,9 +1591,9 @@
     String parentCommentUuid =
         changeOperations.change(changeId).currentPatchset().newComment().create();
 
-    CommentInput createdCommentInput = newComment(COMMIT_MSG, "comment reply");
+    CommentInput createdCommentInput = CommentsUtil.newComment(COMMIT_MSG, "comment reply");
     createdCommentInput.inReplyTo = parentCommentUuid;
-    addComments(changeId, createdCommentInput);
+    CommentsUtil.addComments(gApi, changeId, createdCommentInput);
 
     CommentInfo resultNewComment =
         Iterables.getOnlyElement(
@@ -1655,9 +1609,9 @@
     String parentRobotCommentUuid =
         changeOperations.change(changeId).currentPatchset().newRobotComment().create();
 
-    CommentInput createdCommentInput = newComment(COMMIT_MSG, "comment reply");
+    CommentInput createdCommentInput = CommentsUtil.newComment(COMMIT_MSG, "comment reply");
     createdCommentInput.inReplyTo = parentRobotCommentUuid;
-    addComments(changeId, createdCommentInput);
+    CommentsUtil.addComments(gApi, changeId, createdCommentInput);
 
     CommentInfo resultNewComment =
         Iterables.getOnlyElement(
@@ -1671,11 +1625,12 @@
   public void cannotCreateCommentWithInvalidInReplyTo() throws Exception {
     Change.Id changeId = changeOperations.newChange().create();
 
-    CommentInput comment = newComment(COMMIT_MSG, "comment 1 reply");
+    CommentInput comment = CommentsUtil.newComment(COMMIT_MSG, "comment 1 reply");
     comment.inReplyTo = "invalid";
 
     BadRequestException exception =
-        assertThrows(BadRequestException.class, () -> addComments(changeId, comment));
+        assertThrows(
+            BadRequestException.class, () -> CommentsUtil.addComments(gApi, changeId, comment));
     assertThat(exception.getMessage()).contains(String.format("%s not found", comment.inReplyTo));
   }
 
@@ -1685,27 +1640,6 @@
         .collect(toList());
   }
 
-  private CommentInput addComment(String changeId) throws Exception {
-    ReviewInput input = new ReviewInput();
-    CommentInput comment = newComment(FILE_NAME, Side.REVISION, 0, "a message", false);
-    input.comments = ImmutableMap.of(comment.path, Lists.newArrayList(comment));
-    gApi.changes().id(changeId).current().review(input);
-    return comment;
-  }
-
-  private void addComments(Change.Id changeId, CommentInput... commentInputs) throws Exception {
-    ReviewInput input = new ReviewInput();
-    input.comments = Arrays.stream(commentInputs).collect(groupingBy(c -> c.path));
-    gApi.changes().id(changeId.get()).current().review(input);
-  }
-
-  private void addComments(String changeId, String revision, CommentInput... commentInputs)
-      throws Exception {
-    ReviewInput input = new ReviewInput();
-    input.comments = Arrays.stream(commentInputs).collect(groupingBy(c -> c.path));
-    gApi.changes().id(changeId).revision(revision).review(input);
-  }
-
   /**
    * All the commits, which contain the target comment before, should still contain the comment with
    * the updated message. All the other metas of the commits should be exactly the same.
@@ -1766,64 +1700,6 @@
     return m.matches() ? m.group(1) : msg;
   }
 
-  private ReviewInput newInput(CommentInput c) {
-    ReviewInput in = new ReviewInput();
-    in.comments = new HashMap<>();
-    in.comments.put(c.path, Lists.newArrayList(c));
-    return in;
-  }
-
-  private void addComment(PushOneCommit.Result r, String message) throws Exception {
-    addComment(r, message, false, false, null, null, null);
-  }
-
-  private void addCommentOnLine(PushOneCommit.Result r, String message, int line) throws Exception {
-    addComment(r, message, false, false, null, line, null);
-  }
-
-  private void addCommentOnRange(PushOneCommit.Result r, String message, Comment.Range range)
-      throws Exception {
-    addComment(r, message, false, false, null, null, range);
-  }
-
-  private Comment.Range commentRangeInLines(int startLine, int endLine) {
-    Comment.Range range = new Comment.Range();
-    range.startLine = startLine;
-    range.endLine = endLine;
-    return range;
-  }
-
-  private void addComment(
-      PushOneCommit.Result r,
-      String message,
-      boolean omitDuplicateComments,
-      Boolean unresolved,
-      String inReplyTo)
-      throws Exception {
-    addComment(r, message, omitDuplicateComments, unresolved, inReplyTo, null, null);
-  }
-
-  private void addComment(
-      PushOneCommit.Result r,
-      String message,
-      boolean omitDuplicateComments,
-      Boolean unresolved,
-      String inReplyTo,
-      Integer line,
-      Comment.Range range)
-      throws Exception {
-    CommentInput c = new CommentInput();
-    c.line = line == null ? 1 : line;
-    c.message = message;
-    c.path = FILE_NAME;
-    c.unresolved = unresolved;
-    c.inReplyTo = inReplyTo;
-    c.range = range;
-    ReviewInput in = newInput(c);
-    in.omitDuplicateComments = omitDuplicateComments;
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
-  }
-
   private CommentInfo addDraft(String changeId, String revId, DraftInput in) throws Exception {
     return gApi.changes().id(changeId).revision(revId).createDraft(in).get();
   }
@@ -1876,78 +1752,6 @@
     return gApi.changes().id(changeId).revision(revId).draft(uuid).get();
   }
 
-  private static CommentInput newComment(String file, String message) {
-    return newComment(file, Side.REVISION, 0, message, false);
-  }
-
-  private static CommentInput newCommentWithOnlyMandatoryFields(String path, String message) {
-    CommentInput c = new CommentInput();
-    c.unresolved = false;
-    return populate(c, path, null, null, null, null, message);
-  }
-
-  private static CommentInput newComment(
-      String path, Side side, int line, String message, Boolean unresolved) {
-    CommentInput c = new CommentInput();
-    c.unresolved = unresolved;
-    return populate(c, path, side, null, line, message);
-  }
-
-  private static CommentInput newCommentOnParent(
-      String path, int parent, int line, String message) {
-    CommentInput c = new CommentInput();
-    c.unresolved = false;
-    return populate(c, path, Side.PARENT, parent, line, message);
-  }
-
-  private DraftInput newDraft(String path, Side side, int line, String message) {
-    DraftInput d = new DraftInput();
-    d.unresolved = false;
-    return populate(d, path, side, null, line, message);
-  }
-
-  private DraftInput newDraft(String path, Side side, Comment.Range range, String message) {
-    DraftInput d = new DraftInput();
-    d.unresolved = false;
-    return populate(d, path, side, null, range.startLine, range, message);
-  }
-
-  private DraftInput newDraftOnParent(String path, int parent, int line, String message) {
-    DraftInput d = new DraftInput();
-    d.unresolved = false;
-    return populate(d, path, Side.PARENT, parent, line, message);
-  }
-
-  private DraftInput newDraftWithOnlyMandatoryFields(String path, String message) {
-    DraftInput d = new DraftInput();
-    d.unresolved = false;
-    return populate(d, path, null, null, null, null, message);
-  }
-
-  private static <C extends Comment> C populate(
-      C c,
-      String path,
-      Side side,
-      Integer parent,
-      Integer line,
-      Comment.Range range,
-      String message) {
-    c.path = path;
-    c.side = side;
-    c.parent = parent;
-    c.line = line != null && line != 0 ? line : null;
-    c.message = message;
-    if (range != null) {
-      c.range = range;
-    }
-    return c;
-  }
-
-  private static <C extends Comment> C populate(
-      C c, String path, Side side, Integer parent, int line, String message) {
-    return populate(c, path, side, parent, line, null, message);
-  }
-
   private static Comment.Range createLineRange(int startChar, int endChar) {
     Comment.Range range = new Comment.Range();
     range.startLine = 1;
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsUtil.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsUtil.java
new file mode 100644
index 0000000..c4927f0
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsUtil.java
@@ -0,0 +1,193 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.change;
+
+import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
+import static java.util.stream.Collectors.groupingBy;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.client.Comment;
+import com.google.gerrit.extensions.client.Side;
+import java.util.Arrays;
+import java.util.HashMap;
+
+/**
+ * A utility class for creating {@link CommentInput} objects, publishing comments and creating draft
+ * comments. Used by tests that require dealing with comments.
+ */
+class CommentsUtil {
+  static CommentInput addComment(GerritApi gApi, String changeId) throws Exception {
+    ReviewInput input = new ReviewInput();
+    CommentInput comment = CommentsUtil.newComment(FILE_NAME, Side.REVISION, 0, "a message", false);
+    input.comments = ImmutableMap.of(comment.path, Lists.newArrayList(comment));
+    gApi.changes().id(changeId).current().review(input);
+    return comment;
+  }
+
+  static void addComments(GerritApi gApi, Change.Id changeId, CommentInput... commentInputs)
+      throws Exception {
+    ReviewInput input = new ReviewInput();
+    input.comments = Arrays.stream(commentInputs).collect(groupingBy(c -> c.path));
+    gApi.changes().id(changeId.get()).current().review(input);
+  }
+
+  static void addComments(
+      GerritApi gApi, String changeId, String revision, CommentInput... commentInputs)
+      throws Exception {
+    ReviewInput input = new ReviewInput();
+    input.comments = Arrays.stream(commentInputs).collect(groupingBy(c -> c.path));
+    gApi.changes().id(changeId).revision(revision).review(input);
+  }
+
+  static CommentInput newComment(String file, String message) {
+    return newComment(file, Side.REVISION, 0, message, false);
+  }
+
+  static CommentInput newCommentWithOnlyMandatoryFields(String path, String message) {
+    CommentInput c = new CommentInput();
+    c.unresolved = false;
+    return populate(c, path, null, null, null, null, message);
+  }
+
+  static CommentInput newComment(
+      String path, Side side, int line, String message, Boolean unresolved) {
+    CommentInput c = new CommentInput();
+    c.unresolved = unresolved;
+    return populate(c, path, side, null, line, message);
+  }
+
+  static CommentInput newComment(
+      String path, Side side, Comment.Range range, String message, Boolean unresolved) {
+    CommentInput c = new CommentInput();
+    c.unresolved = unresolved;
+    return populate(c, path, side, null, null, range, message);
+  }
+
+  static CommentInput newCommentOnParent(String path, int parent, int line, String message) {
+    CommentInput c = new CommentInput();
+    c.unresolved = false;
+    return populate(c, path, Side.PARENT, parent, line, message);
+  }
+
+  static DraftInput newDraft(String path, Side side, int line, String message) {
+    DraftInput d = new DraftInput();
+    d.unresolved = false;
+    return populate(d, path, side, null, line, message);
+  }
+
+  static DraftInput newDraft(String path, Side side, Comment.Range range, String message) {
+    DraftInput d = new DraftInput();
+    d.unresolved = false;
+    return populate(d, path, side, null, range.startLine, range, message);
+  }
+
+  static DraftInput newDraftOnParent(String path, int parent, int line, String message) {
+    DraftInput d = new DraftInput();
+    d.unresolved = false;
+    return populate(d, path, Side.PARENT, parent, line, message);
+  }
+
+  static DraftInput newDraftWithOnlyMandatoryFields(String path, String message) {
+    DraftInput d = new DraftInput();
+    d.unresolved = false;
+    return populate(d, path, null, null, null, null, message);
+  }
+
+  static <C extends Comment> C populate(
+      C c,
+      String path,
+      Side side,
+      Integer parent,
+      Integer line,
+      Comment.Range range,
+      String message) {
+    c.path = path;
+    c.side = side;
+    c.parent = parent;
+    c.line = line != null && line != 0 ? line : null;
+    c.message = message;
+    if (range != null) {
+      c.range = range;
+    }
+    return c;
+  }
+
+  static <C extends Comment> C populate(
+      C c, String path, Side side, Integer parent, int line, String message) {
+    return populate(c, path, side, parent, line, null, message);
+  }
+
+  static ReviewInput newInput(CommentInput c) {
+    ReviewInput in = new ReviewInput();
+    in.comments = new HashMap<>();
+    in.comments.put(c.path, Lists.newArrayList(c));
+    return in;
+  }
+
+  static void addComment(GerritApi gApi, PushOneCommit.Result r, String message) throws Exception {
+    addComment(gApi, r, message, false, false, null, null, null);
+  }
+
+  static void addComment(
+      GerritApi gApi,
+      PushOneCommit.Result r,
+      String message,
+      boolean omitDuplicateComments,
+      Boolean unresolved,
+      String inReplyTo)
+      throws Exception {
+    addComment(gApi, r, message, omitDuplicateComments, unresolved, inReplyTo, null, null);
+  }
+
+  static void addCommentOnLine(GerritApi gApi, PushOneCommit.Result r, String message, int line)
+      throws Exception {
+    addComment(gApi, r, message, false, false, null, line, null);
+  }
+
+  static void addCommentOnRange(
+      GerritApi gApi, PushOneCommit.Result r, String message, Comment.Range range)
+      throws Exception {
+    addComment(gApi, r, message, false, false, null, null, range);
+  }
+
+  static void addComment(
+      GerritApi gApi,
+      PushOneCommit.Result r,
+      String message,
+      boolean omitDuplicateComments,
+      Boolean unresolved,
+      String inReplyTo,
+      Integer line,
+      Comment.Range range)
+      throws Exception {
+    CommentInput c = new CommentInput();
+    c.line = line == null ? 1 : line;
+    c.message = message;
+    c.path = FILE_NAME;
+    c.unresolved = unresolved;
+    c.inReplyTo = inReplyTo;
+    c.range = range;
+    ReviewInput in = CommentsUtil.newInput(c);
+    in.omitDuplicateComments = omitDuplicateComments;
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/experiments/BUILD b/javatests/com/google/gerrit/acceptance/server/experiments/BUILD
new file mode 100644
index 0000000..0f01ffa
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/experiments/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "server_experiments",
+    labels = ["server"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java b/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
new file mode 100644
index 0000000..09e6dfe
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
@@ -0,0 +1,77 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.experiments;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+/** Tests for {@link ExperimentFeatures} */
+public class ExperimentFeaturesIT extends AbstractDaemonTest {
+
+  @Inject ExperimentFeatures experimentFeatures;
+
+  @Test
+  public void emptyConfig_defaultFeatures_enabled() {
+    for (String defaultFeature : ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES) {
+      assertThat(experimentFeatures.isFeatureEnabled(defaultFeature)).isTrue();
+    }
+
+    assertThat(experimentFeatures.getEnabledExperimentFeatures())
+        .isEqualTo(ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {"enabledFeature", "enabledThenDisabledFeature"})
+  @GerritConfig(
+      name = "experiments.disabled",
+      values = {"enabledThenDisabledFeature"})
+  public void configOverride_anyFeatureAllowed() {
+    assertThat(experimentFeatures.isFeatureEnabled("enabledFeature")).isTrue();
+    assertThat(experimentFeatures.isFeatureEnabled("enabledThenDisabledFeature")).isFalse();
+    assertThat(experimentFeatures.isFeatureEnabled("unknownFeature")).isFalse();
+    ImmutableSet<String> expectedEnabledFeatures =
+        new ImmutableSet.Builder<String>()
+            .addAll(ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES)
+            .add("enabledFeature")
+            .build();
+    assertThat(experimentFeatures.getEnabledExperimentFeatures())
+        .isEqualTo(expectedEnabledFeatures);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {"enabledFeature"})
+  @GerritConfig(
+      name = "experiments.disabled",
+      values = {"UiFeature__patchset_comments"})
+  public void configOverride_defaultFeatureDisabled() {
+    assertThat(experimentFeatures.isFeatureEnabled("enabledFeature")).isTrue();
+    assertThat(
+            experimentFeatures.isFeatureEnabled(
+                ExperimentFeaturesConstants.UI_FEATURE_PATCHSET_COMMENTS))
+        .isFalse();
+    assertThat(experimentFeatures.getEnabledExperimentFeatures()).containsExactly("enabledFeature");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index 9b12f29..f267513 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
@@ -89,7 +90,7 @@
         .add(allow(Permission.FORGE_COMMITTER).ref("refs/*").group(REGISTERED_USERS))
         .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
         .add(allow(Permission.ABANDON).ref("refs/*").group(REGISTERED_USERS))
-        .add(allowLabel("Code-Review").ref("refs/*").group(REGISTERED_USERS).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/*").group(REGISTERED_USERS).range(-2, +2))
         .update();
   }
 
@@ -1470,14 +1471,14 @@
 
   private void deleteVote(StagedChange sc, TestAccount account) throws Exception {
     sender.clear();
-    gApi.changes().id(sc.changeId).reviewer(account.email()).deleteVote("Code-Review");
+    gApi.changes().id(sc.changeId).reviewer(account.email()).deleteVote(LabelId.CODE_REVIEW);
   }
 
   private void deleteVote(StagedChange sc, TestAccount account, NotifyHandling notify)
       throws Exception {
     sender.clear();
     DeleteVoteInput in = new DeleteVoteInput();
-    in.label = "Code-Review";
+    in.label = LabelId.CODE_REVIEW;
     in.notify = notify;
     gApi.changes().id(sc.changeId).reviewer(account.email()).deleteVote(in);
   }
@@ -1714,7 +1715,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 +1731,7 @@
         .sent("newpatchset", sc)
         .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
         .to(sc.reviewer)
-        .to(other)
+        .cc(other)
         .cc(sc.ccer)
         .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
         .noOneElse();
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/BUILD b/javatests/com/google/gerrit/acceptance/server/permissions/BUILD
new file mode 100644
index 0000000..e89e8d1
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "server_permissions",
+    labels = ["server"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java b/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
new file mode 100644
index 0000000..9e4907c
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
@@ -0,0 +1,301 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.permissions;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.ExternalUser;
+import com.google.gerrit.server.PropertyMap;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.account.GroupMembership;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.AbstractModule;
+import com.google.inject.Module;
+import java.util.Collection;
+import java.util.Set;
+import javax.inject.Inject;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests that permission logic used by {@link ExternalUser} works as expected. */
+public class ExternalUserPermissionIT extends AbstractDaemonTest {
+  private static final AccountGroup.UUID EXTERNAL_GROUP =
+      AccountGroup.uuid("company-auth:it-department");
+
+  @Inject private ChangeOperations changeOperations;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private PermissionBackend permissionBackend;
+  @Inject private ChangeNotes.Factory changeNotesFactory;
+  @Inject private ExternalUser.Factory externalUserFactory;
+  @Inject private GroupOperations groupOperations;
+
+  @Before
+  public void setUp() {
+    // Allow only read on refs/heads/master by default
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .remove(permissionKey(Permission.READ).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .add(allow(Permission.READ).ref("refs/heads/master").group(ANONYMOUS_USERS))
+        .update();
+  }
+
+  @Override
+  public Module createModule() {
+    /**
+     * Binding a {@link GroupBackend} that pretends a user is part of a group if the external ID
+     * starts with the group UUID.
+     *
+     * <p>Example: Users "company-auth:it-department-1" and "company-auth:it-department-2" are a
+     * member of the group "company-auth:it-department"
+     */
+    return new AbstractModule() {
+      @Override
+      protected void configure() {
+        DynamicSet.bind(binder(), GroupBackend.class)
+            .toInstance(
+                new GroupBackend() {
+                  @Override
+                  public boolean handles(AccountGroup.UUID uuid) {
+                    return uuid.get().startsWith("company-auth:");
+                  }
+
+                  @Override
+                  public GroupDescription.Basic get(AccountGroup.UUID uuid) {
+                    return new GroupDescription.Basic() {
+                      @Override
+                      public AccountGroup.UUID getGroupUUID() {
+                        return uuid;
+                      }
+
+                      @Override
+                      public String getName() {
+                        return uuid.get();
+                      }
+
+                      @Override
+                      public String getEmailAddress() {
+                        return uuid.get() + "@example.com";
+                      }
+
+                      @Override
+                      public String getUrl() {
+                        return null;
+                      }
+                    };
+                  }
+
+                  @Override
+                  public Collection<GroupReference> suggest(String name, ProjectState project) {
+                    throw new UnsupportedOperationException("not implemented");
+                  }
+
+                  @Override
+                  public GroupMembership membershipsOf(CurrentUser user) {
+                    return new GroupMembership() {
+                      @Override
+                      public boolean contains(AccountGroup.UUID groupId) {
+                        return user.getExternalIdKeys().stream()
+                            .anyMatch(e -> e.get().startsWith(groupId.get()));
+                      }
+
+                      @Override
+                      public boolean containsAnyOf(Iterable<AccountGroup.UUID> groupIds) {
+                        return ImmutableList.copyOf(groupIds).stream().anyMatch(g -> contains(g));
+                      }
+
+                      @Override
+                      public Set<AccountGroup.UUID> intersection(
+                          Iterable<AccountGroup.UUID> groupIds) {
+                        return ImmutableList.copyOf(groupIds).stream()
+                            .filter(g -> contains(g))
+                            .collect(toImmutableSet());
+                      }
+
+                      @Override
+                      public Set<AccountGroup.UUID> getKnownGroups() {
+                        return ImmutableSet.of();
+                      }
+                    };
+                  }
+
+                  @Override
+                  public boolean isVisibleToAll(AccountGroup.UUID uuid) {
+                    return false;
+                  }
+                });
+      }
+    };
+  }
+
+  @Test
+  public void defaultRefFilter_changeVisibilityIsAgnosticOfProvidedGroups() throws Exception {
+    ExternalUser user = createUserInGroup("1", "it-department");
+
+    Change.Id changeOnMaster = changeOperations.newChange().project(project).create();
+    Change.Id changeOnRefsMetaConfig =
+        changeOperations.newChange().project(project).branch("refs/meta/config").create();
+    // Check that only the change on the default branch is visible
+    assertThat(getVisibleRefNames(user))
+        .containsExactly(
+            "HEAD",
+            "refs/heads/master",
+            RefNames.changeMetaRef(changeOnMaster),
+            RefNames.patchSetRef(PatchSet.id(changeOnMaster, 1)));
+    // Grant access
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(EXTERNAL_GROUP))
+        .update();
+    // Check that both changes are visible now
+    assertThat(getVisibleRefNames(user))
+        .containsExactly(
+            "HEAD",
+            "refs/heads/master",
+            "refs/meta/config",
+            RefNames.changeMetaRef(changeOnMaster),
+            RefNames.patchSetRef(PatchSet.id(changeOnMaster, 1)),
+            RefNames.changeMetaRef(changeOnRefsMetaConfig),
+            RefNames.patchSetRef(PatchSet.id(changeOnRefsMetaConfig, 1)));
+  }
+
+  @Test
+  public void defaultRefFilter_refVisibilityIsAgnosticOfProvidedGroups() throws Exception {
+    ExternalUser user = createUserInGroup("1", "it-department");
+    // Check that refs/meta/config isn't visible by default
+    assertThat(getVisibleRefNames(user)).containsExactly("HEAD", "refs/heads/master");
+    // Grant access
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(EXTERNAL_GROUP))
+        .update();
+    // Check that refs/meta/config became visible
+    assertThat(getVisibleRefNames(user))
+        .containsExactly("HEAD", "refs/heads/master", "refs/meta/config");
+  }
+
+  @Test
+  public void changeVisibility_changeOnInvisibleBranchNotVisible() throws Exception {
+    // Create a change that is not visible to members of 'externalGroup'
+    Change.Id invisibleChange =
+        changeOperations.newChange().project(project).branch("refs/meta/config").create();
+    ExternalUser user = createUserInGroup("1", "it-department");
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                permissionBackend
+                    .user(user)
+                    .change(changeNotesFactory.create(project, invisibleChange))
+                    .check(ChangePermission.READ));
+    assertThat(thrown).hasMessageThat().isEqualTo("read not permitted");
+  }
+
+  @Test
+  public void changeVisibility_changeOnBranchVisibleToAnonymousIsVisible() throws Exception {
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+    ExternalUser user = createUserInGroup("1", "it-department");
+    permissionBackend
+        .user(user)
+        .change(changeNotesFactory.create(project, changeId))
+        .check(ChangePermission.READ);
+  }
+
+  @Test
+  public void changeVisibility_changeOnBranchVisibleToRegisteredUsersIsVisible() throws Exception {
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+    ExternalUser user = createUserInGroup("1", "it-department");
+    blockAnonymousRead();
+    permissionBackend
+        .user(user)
+        .change(changeNotesFactory.create(project, changeId))
+        .check(ChangePermission.READ);
+  }
+
+  @Test
+  public void externalUser_isContainedInternalGroupThatContainsExternalGroup() {
+    AccountGroup.UUID internalGroup =
+        groupOperations.newGroup().addSubgroup(EXTERNAL_GROUP).create();
+    ExternalUser user = createUserInGroup("1", "it-department");
+    assertThat(user.getEffectiveGroups().contains(internalGroup)).isTrue();
+    assertThat(user.getEffectiveGroups().contains(EXTERNAL_GROUP)).isTrue();
+    assertThat(user.getEffectiveGroups().contains(REGISTERED_USERS)).isTrue();
+    assertThat(user.getEffectiveGroups().contains(ANONYMOUS_USERS)).isTrue();
+  }
+
+  @GerritConfig(name = "groups.includeExternalUsersInRegisteredUsersGroup", value = "true")
+  @Test
+  public void externalUser_isContainedInRegisteredUsersIfConfigured() {
+    ExternalUser user = createUserInGroup("1", "it-department");
+    assertThat(user.getEffectiveGroups().contains(REGISTERED_USERS)).isTrue();
+  }
+
+  @GerritConfig(name = "groups.includeExternalUsersInRegisteredUsersGroup", value = "false")
+  @Test
+  public void externalUser_isNotContainedInRegisteredUsersIfNotConfigured() {
+    ExternalUser user = createUserInGroup("1", "it-department");
+    assertThat(user.getEffectiveGroups().contains(REGISTERED_USERS)).isFalse();
+  }
+
+  private ImmutableList<String> getVisibleRefNames(CurrentUser user) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      return permissionBackend.user(user).project(project)
+          .filter(
+              repo.getRefDatabase().getRefs(), repo, PermissionBackend.RefFilterOptions.defaults())
+          .stream()
+          .map(Ref::getName)
+          .collect(toImmutableList());
+    }
+  }
+
+  ExternalUser createUserInGroup(String userId, String groupId) {
+    return externalUserFactory.create(
+        ImmutableSet.of(),
+        ImmutableSet.of(ExternalId.Key.parse("company-auth:" + groupId + "-" + userId)),
+        PropertyMap.EMPTY);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/GroupBackedUserPermissionIT.java b/javatests/com/google/gerrit/acceptance/server/permissions/GroupBackedUserPermissionIT.java
new file mode 100644
index 0000000..d68d681
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/GroupBackedUserPermissionIT.java
@@ -0,0 +1,184 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.permissions;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.group.testing.TestGroupBackend;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.query.change.GroupBackedUser;
+import com.google.inject.AbstractModule;
+import com.google.inject.Module;
+import javax.inject.Inject;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests that permission logic used by {@link GroupBackedUser} works as expected. */
+public class GroupBackedUserPermissionIT extends AbstractDaemonTest {
+  @Inject private ChangeOperations changeOperations;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private PermissionBackend permissionBackend;
+  @Inject private ChangeNotes.Factory changeNotesFactory;
+
+  private final TestGroupBackend testGroupBackend = new TestGroupBackend();
+  private final AccountGroup.UUID externalGroup = AccountGroup.uuid("testbackend:test");
+
+  @Before
+  public void setUp() {
+    // Allow only read on refs/heads/master by default
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .remove(permissionKey(Permission.READ).ref("refs/*").group(ANONYMOUS_USERS))
+        .add(allow(Permission.READ).ref("refs/heads/master").group(ANONYMOUS_USERS))
+        .update();
+  }
+
+  @Override
+  public Module createModule() {
+    /** Binding a {@link TestGroupBackend} to test adding external groups * */
+    return new AbstractModule() {
+      @Override
+      protected void configure() {
+        DynamicSet.bind(binder(), GroupBackend.class).toInstance(testGroupBackend);
+      }
+    };
+  }
+
+  @Test
+  public void defaultRefFilter_changeVisibilityIsAgnosticOfProvidedGroups() throws Exception {
+    GroupBackedUser user =
+        new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+    Change.Id changeOnMaster = changeOperations.newChange().project(project).create();
+    Change.Id changeOnRefsMetaConfig =
+        changeOperations.newChange().project(project).branch("refs/meta/config").create();
+    // Check that only the change on the default branch is visible
+    assertThat(getVisibleRefNames(user))
+        .containsExactly(
+            "HEAD",
+            "refs/heads/master",
+            RefNames.changeMetaRef(changeOnMaster),
+            RefNames.patchSetRef(PatchSet.id(changeOnMaster, 1)));
+    // Grant access
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(externalGroup))
+        .update();
+    // Check that both changes are visible now
+    assertThat(getVisibleRefNames(user))
+        .containsExactly(
+            "HEAD",
+            "refs/heads/master",
+            "refs/meta/config",
+            RefNames.changeMetaRef(changeOnMaster),
+            RefNames.patchSetRef(PatchSet.id(changeOnMaster, 1)),
+            RefNames.changeMetaRef(changeOnRefsMetaConfig),
+            RefNames.patchSetRef(PatchSet.id(changeOnRefsMetaConfig, 1)));
+  }
+
+  @Test
+  public void defaultRefFilter_refVisibilityIsAgnosticOfProvidedGroups() throws Exception {
+    GroupBackedUser user =
+        new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+    // Check that refs/meta/config isn't visible by default
+    assertThat(getVisibleRefNames(user)).containsExactly("HEAD", "refs/heads/master");
+    // Grant access
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(externalGroup))
+        .update();
+    // Check that refs/meta/config became visible
+    assertThat(getVisibleRefNames(user))
+        .containsExactly("HEAD", "refs/heads/master", "refs/meta/config");
+  }
+
+  @Test
+  public void changeVisibility_changeOnInvisibleBranchNotVisible() throws Exception {
+    // Create a change that is not visible to members of 'externalGroup'
+    Change.Id invisibleChange =
+        changeOperations.newChange().project(project).branch("refs/meta/config").create();
+    GroupBackedUser user =
+        new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                permissionBackend
+                    .user(user)
+                    .change(changeNotesFactory.create(project, invisibleChange))
+                    .check(ChangePermission.READ));
+    assertThat(thrown).hasMessageThat().isEqualTo("read not permitted");
+  }
+
+  @Test
+  public void changeVisibility_changeOnBranchVisibleToAnonymousIsVisible() throws Exception {
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+    GroupBackedUser user =
+        new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+    permissionBackend
+        .user(user)
+        .change(changeNotesFactory.create(project, changeId))
+        .check(ChangePermission.READ);
+  }
+
+  @Test
+  public void changeVisibility_changeOnBranchVisibleToRegisteredUsersIsVisible() throws Exception {
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+    GroupBackedUser user =
+        new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+    blockAnonymousRead();
+    permissionBackend
+        .user(user)
+        .change(changeNotesFactory.create(project, changeId))
+        .check(ChangePermission.READ);
+  }
+
+  private ImmutableList<String> getVisibleRefNames(CurrentUser user) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      return permissionBackend.user(user).project(project)
+          .filter(
+              repo.getRefDatabase().getRefs(), repo, PermissionBackend.RefFilterOptions.defaults())
+          .stream()
+          .map(Ref::getName)
+          .collect(toImmutableList());
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java b/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java
index d3b40cc..f26f5f3 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.entities.SubmitRequirement;
@@ -37,7 +38,7 @@
 
   @Test
   public void blocksWhenUploaderIsOnlyApprover() throws Exception {
-    enableRule("Code-Review", true);
+    enableRule(LabelId.CODE_REVIEW, true);
 
     PushOneCommit.Result r = createChange();
     approve(r.getChangeId());
@@ -58,7 +59,7 @@
 
   @Test
   public void allowsSubmissionWhenChangeHasNonUploaderApproval() throws Exception {
-    enableRule("Code-Review", true);
+    enableRule(LabelId.CODE_REVIEW, true);
 
     // Create change as user
     TestRepository<InMemoryRepository> userTestRepo = cloneProject(project, user);
@@ -74,7 +75,7 @@
 
   @Test
   public void doesNothingByDefault() throws Exception {
-    enableRule("Code-Review", false);
+    enableRule(LabelId.CODE_REVIEW, false);
 
     PushOneCommit.Result r = createChange();
     approve(r.getChangeId());
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java
index 6079388..bf8b1f8 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.PrologOptions;
@@ -41,7 +42,7 @@
   public void convertsPrologToSubmitRecord() {
     PrologRuleEvaluator evaluator = makeEvaluator();
 
-    StructureTerm verifiedLabel = makeLabel("Verified", "may");
+    StructureTerm verifiedLabel = makeLabel(LabelId.VERIFIED, "may");
     StructureTerm labels = new StructureTerm("label", verifiedLabel);
 
     List<Term> terms = ImmutableList.of(makeTerm("ok", labels));
@@ -85,12 +86,12 @@
     PrologRuleEvaluator evaluator = makeEvaluator();
 
     SubmitRecord.Label submitRecordLabel1 = new SubmitRecord.Label();
-    submitRecordLabel1.label = "Verified";
+    submitRecordLabel1.label = LabelId.VERIFIED;
     submitRecordLabel1.status = SubmitRecord.Label.Status.REJECT;
     submitRecordLabel1.appliedBy = admin.id();
 
     SubmitRecord.Label submitRecordLabel2 = new SubmitRecord.Label();
-    submitRecordLabel2.label = "Code-Review";
+    submitRecordLabel2.label = LabelId.CODE_REVIEW;
     submitRecordLabel2.status = SubmitRecord.Label.Status.OK;
     submitRecordLabel2.appliedBy = admin.id();
 
diff --git a/javatests/com/google/gerrit/acceptance/server/util/BUILD b/javatests/com/google/gerrit/acceptance/server/util/BUILD
new file mode 100644
index 0000000..ea25784
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/util/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "server_util",
+    labels = ["server"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/util/PluginLogFileIT.java b/javatests/com/google/gerrit/acceptance/server/util/PluginLogFileIT.java
new file mode 100644
index 0000000..06bf1ae
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/util/PluginLogFileIT.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.util;
+
+import static junit.framework.TestCase.assertEquals;
+import static org.junit.Assert.fail;
+
+import com.google.gerrit.acceptance.AbstractPluginLogFileTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.inject.Inject;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import org.junit.Test;
+
+@NoHttpd
+@UseSsh
+public class PluginLogFileIT extends AbstractPluginLogFileTest {
+  @Inject private InvocationCounter invocationCounter;
+  private static final int NUMBER_OF_THREADS = 5;
+
+  @Test
+  public void testMultiThreadedPluginLogFile() throws Exception {
+    try (AutoCloseable ignored = installPlugin("my-plugin", Module.class)) {
+      ExecutorService service = Executors.newFixedThreadPool(NUMBER_OF_THREADS);
+      CountDownLatch latch = new CountDownLatch(NUMBER_OF_THREADS);
+      createChange();
+      for (int i = 0; i < NUMBER_OF_THREADS; i++) {
+        service.execute(
+            () -> {
+              try {
+                adminSshSession.exec("gerrit query --format json status:open --my-plugin--opt");
+                adminSshSession.assertSuccess();
+              } catch (Exception e) {
+                fail(e.getMessage());
+              } finally {
+                latch.countDown();
+              }
+            });
+      }
+      latch.await();
+      assertEquals(1, invocationCounter.getCounter());
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/DynamicOptionsIT.java b/javatests/com/google/gerrit/acceptance/ssh/DynamicOptionsIT.java
new file mode 100644
index 0000000..4efa247
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ssh/DynamicOptionsIT.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.ssh;
+
+import static com.google.gerrit.server.query.change.OutputStreamQuery.GSON;
+import static junit.framework.TestCase.assertEquals;
+
+import com.google.common.collect.Lists;
+import com.google.gerrit.acceptance.AbstractDynamicOptionsTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gson.reflect.TypeToken;
+import com.google.inject.Module;
+import java.util.List;
+import org.junit.Test;
+
+@NoHttpd
+@UseSsh
+public class DynamicOptionsIT extends AbstractDynamicOptionsTest {
+
+  @Override
+  public Module createSshModule() {
+    return new AbstractDynamicOptionsTest.PluginOneSshModule();
+  }
+
+  @Test
+  public void testDynamicPluginOptions() throws Exception {
+    try (AutoCloseable ignored =
+        installPlugin("my-plugin", AbstractDynamicOptionsTest.PluginTwoModule.class)) {
+      List<String> samples = getSamplesList(adminSshSession.exec("ls-samples"));
+      adminSshSession.assertSuccess();
+      assertEquals(Lists.newArrayList("sample1", "sample2"), samples);
+    }
+  }
+
+  protected List<String> getSamplesList(String sshOutput) {
+    return GSON.fromJson(sshOutput, new TypeToken<List<String>>() {}.getType());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/LifecycleListenersIT.java b/javatests/com/google/gerrit/acceptance/ssh/LifecycleListenersIT.java
new file mode 100644
index 0000000..0596cad
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ssh/LifecycleListenersIT.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.ssh;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.google.gerrit.acceptance.AbstractLifecycleListenersTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.inject.Inject;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+@UseSsh
+public class LifecycleListenersIT extends AbstractLifecycleListenersTest {
+  @Inject private InvocationCheck invocationCheck;
+
+  @Before
+  public void before() {
+    invocationCheck.setStartInvoked(false);
+    invocationCheck.setStopInvoked(false);
+  }
+
+  @Test
+  public void lifecycleListenerSuccessfulInvocation() throws Exception {
+    try (AutoCloseable ignored = installPlugin("my-plugin", SimpleModule.class)) {
+      adminSshSession.exec("gerrit query --format json status:open --my-plugin--opt");
+      adminSshSession.assertSuccess();
+      assertTrue(invocationCheck.isStartInvoked());
+      assertTrue(invocationCheck.isStopInvoked());
+    }
+  }
+
+  @Test
+  public void lifecycleListenerUnsuccessfulInvocation() throws Exception {
+    try (AutoCloseable ignored = installPlugin("my-plugin", SimpleModule.class)) {
+      adminSshSession.exec("gerrit ls-projects");
+      adminSshSession.assertSuccess();
+      assertFalse(invocationCheck.isStartInvoked());
+      assertFalse(invocationCheck.isStopInvoked());
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java b/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java
index 38293f9..009e05d 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.acceptance.ssh;
 
-import static com.google.common.truth.Truth.assertThat;
 import static java.util.stream.Collectors.joining;
 
 import com.google.common.collect.ImmutableListMultimap;
@@ -41,31 +40,12 @@
   private static final Gson GSON = OutputStreamQuery.GSON;
 
   @Test
-  public void queryChangeWithNullAttribute() throws Exception {
-    getChangeWithNullAttribute(
-        id -> pluginInfoFromSingletonList(adminSshSession.exec(changeQueryCmd(id))));
-  }
-
-  @Test
-  public void queryChangeWithSimpleAttribute() throws Exception {
-    getChangeWithSimpleAttribute(
-        id -> pluginInfoFromSingletonList(adminSshSession.exec(changeQueryCmd(id))));
-  }
-
-  @Test
   public void querySingleChangeWithBulkAttribute() throws Exception {
     getSingleChangeWithPluginDefinedBulkAttribute(
         id -> pluginInfosFromList(adminSshSession.exec(changeQueryCmd(id))));
   }
 
   @Test
-  public void queryChangeWithOption() throws Exception {
-    getChangeWithOption(
-        id -> pluginInfoFromSingletonList(adminSshSession.exec(changeQueryCmd(id))),
-        (id, opts) -> pluginInfoFromSingletonList(adminSshSession.exec(changeQueryCmd(id, opts))));
-  }
-
-  @Test
   public void queryPluginDefinedAttributeChangeWithOption() throws Exception {
     getChangeWithPluginDefinedBulkAttributeOption(
         id -> pluginInfosFromList(adminSshSession.exec(changeQueryCmd(id))),
@@ -85,12 +65,6 @@
   }
 
   @Test
-  public void getMultipleChangesWithPluginDefinedAndChangeAttributes() throws Exception {
-    getMultipleChangesWithPluginDefinedBulkAndChangeAttributes(
-        () -> pluginInfosFromList(adminSshSession.exec("gerrit query --format json status:open")));
-  }
-
-  @Test
   public void getMultipleChangesWithPluginDefinedAttributeInSingleCall() throws Exception {
     getMultipleChangesWithPluginDefinedBulkAttributeInSingleCall(
         () -> pluginInfosFromList(adminSshSession.exec("gerrit query --format json status:open")));
@@ -116,15 +90,6 @@
   }
 
   @Nullable
-  private static List<PluginDefinedInfo> pluginInfoFromSingletonList(String sshOutput)
-      throws Exception {
-    List<Map<String, Object>> changeAttrs = getChangeAttrs(sshOutput);
-
-    assertThat(changeAttrs).hasSize(1);
-    return decodeRawPluginsList(GSON, changeAttrs.get(0).get("plugins"));
-  }
-
-  @Nullable
   private static Map<Change.Id, List<PluginDefinedInfo>> pluginInfosFromList(String sshOutput)
       throws Exception {
     List<Map<String, Object>> changeAttrs = getChangeAttrs(sshOutput);
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
index f6421a5..48fd38c 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableSet;
@@ -29,7 +28,6 @@
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.CurrentUser.PropertyKey;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -75,19 +73,6 @@
   }
 
   @Test
-  public void resetCurrentApiUserClearsCachedState() throws Exception {
-    requestScopeOperations.setApiUser(user.id());
-    PropertyKey<String> key = PropertyKey.create();
-    atrScope.get().getUser().put(key, "foo");
-    assertThat(atrScope.get().getUser().get(key)).hasValue("foo");
-
-    AcceptanceTestRequestScope.Context oldCtx = requestScopeOperations.resetCurrentApiUser();
-    checkCurrentUser(user.id());
-    assertThat(atrScope.get().getUser().get(key)).isEmpty();
-    assertThat(oldCtx.getUser().get(key)).hasValue("foo");
-  }
-
-  @Test
   public void setApiUserAnonymousSetsAnonymousUser() throws Exception {
     fastCheckCurrentUser(admin.id());
     requestScopeOperations.setApiUserAnonymous();
diff --git a/javatests/com/google/gerrit/auth/BUILD b/javatests/com/google/gerrit/auth/BUILD
new file mode 100644
index 0000000..6a41d01
--- /dev/null
+++ b/javatests/com/google/gerrit/auth/BUILD
@@ -0,0 +1,39 @@
+load("@rules_java//java:defs.bzl", "java_library")
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "auth_tests",
+    size = "medium",
+    srcs = glob(
+        ["**/*.java"],
+    ),
+    resource_strip_prefix = "resources",
+    resources = ["//resources/com/google/gerrit/server"],
+    tags = ["no_windows"],
+    visibility = ["//visibility:public"],
+    runtime_deps = [
+        "//java/com/google/gerrit/lucene",
+        "//lib/bouncycastle:bcprov",
+        "//prolog:gerrit-prolog-common",
+    ],
+    deps = [
+        "//java/com/google/gerrit/auth",
+        "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/proto/testing",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/cache/serialize",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
+        "//lib:guava-retrying",
+        "//lib:jgit",
+        "//lib:jgit-junit",
+        "//lib:protobuf",
+        "//lib/flogger:api",
+        "//lib/guice",
+        "//lib/truth",
+        "//lib/truth:truth-java8-extension",
+        "//lib/truth:truth-proto-extension",
+        "//proto:cache_java_proto",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/auth/ldap/LdapRealmTest.java b/javatests/com/google/gerrit/auth/ldap/LdapRealmTest.java
similarity index 91%
rename from javatests/com/google/gerrit/server/auth/ldap/LdapRealmTest.java
rename to javatests/com/google/gerrit/auth/ldap/LdapRealmTest.java
index ba40d8c..82bace2 100644
--- a/javatests/com/google/gerrit/server/auth/ldap/LdapRealmTest.java
+++ b/javatests/com/google/gerrit/auth/ldap/LdapRealmTest.java
@@ -12,16 +12,16 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.ldap;
+package com.google.gerrit.auth.ldap;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.auth.ldap.LdapModule.GROUP_CACHE;
+import static com.google.gerrit.auth.ldap.LdapModule.GROUP_EXIST_CACHE;
+import static com.google.gerrit.auth.ldap.LdapModule.PARENT_GROUPS_CACHE;
+import static com.google.gerrit.auth.ldap.LdapModule.USERNAME_CACHE;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
-import static com.google.gerrit.server.auth.ldap.LdapModule.GROUP_CACHE;
-import static com.google.gerrit.server.auth.ldap.LdapModule.GROUP_EXIST_CACHE;
-import static com.google.gerrit.server.auth.ldap.LdapModule.PARENT_GROUPS_CACHE;
-import static com.google.gerrit.server.auth.ldap.LdapModule.USERNAME_CACHE;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
diff --git a/javatests/com/google/gerrit/server/auth/oauth/OAuthRealmTest.java b/javatests/com/google/gerrit/auth/oauth/OAuthRealmTest.java
similarity index 98%
rename from javatests/com/google/gerrit/server/auth/oauth/OAuthRealmTest.java
rename to javatests/com/google/gerrit/auth/oauth/OAuthRealmTest.java
index dc62a61..6d2d052 100644
--- a/javatests/com/google/gerrit/server/auth/oauth/OAuthRealmTest.java
+++ b/javatests/com/google/gerrit/auth/oauth/OAuthRealmTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.oauth;
+package com.google.gerrit.auth.oauth;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_EXTERNAL;
diff --git a/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java b/javatests/com/google/gerrit/auth/oauth/OAuthTokenCacheTest.java
similarity index 98%
rename from javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
rename to javatests/com/google/gerrit/auth/oauth/OAuthTokenCacheTest.java
index 64fa74f..e3357b8 100644
--- a/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
+++ b/javatests/com/google/gerrit/auth/oauth/OAuthTokenCacheTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.oauth;
+package com.google.gerrit.auth.oauth;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
diff --git a/javatests/com/google/gerrit/elasticsearch/BUILD b/javatests/com/google/gerrit/elasticsearch/BUILD
index be35d5a..3036811 100644
--- a/javatests/com/google/gerrit/elasticsearch/BUILD
+++ b/javatests/com/google/gerrit/elasticsearch/BUILD
@@ -55,7 +55,6 @@
 ELASTICSEARCH_TAGS = [
     "docker",
     "elastic",
-    "exclusive",
 ]
 
 [junit_tests(
diff --git a/javatests/com/google/gerrit/entities/LabelFunctionTest.java b/javatests/com/google/gerrit/entities/LabelFunctionTest.java
index 3941564..0132697 100644
--- a/javatests/com/google/gerrit/entities/LabelFunctionTest.java
+++ b/javatests/com/google/gerrit/entities/LabelFunctionTest.java
@@ -24,7 +24,7 @@
 import org.junit.Test;
 
 public class LabelFunctionTest {
-  private static final String LABEL_NAME = "Verified";
+  private static final String LABEL_NAME = LabelId.VERIFIED;
   private static final LabelId LABEL_ID = LabelId.create(LABEL_NAME);
   private static final Change.Id CHANGE_ID = Change.id(100);
   private static final PatchSet.Id PS_ID = PatchSet.id(CHANGE_ID, 1);
@@ -39,7 +39,7 @@
   public void checkLabelNameIsCorrect() {
     for (LabelFunction function : LabelFunction.values()) {
       SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, ImmutableList.of());
-      assertThat(myLabel.label).isEqualTo("Verified");
+      assertThat(myLabel.label).isEqualTo(LabelId.VERIFIED);
     }
   }
 
diff --git a/javatests/com/google/gerrit/entities/PermissionTest.java b/javatests/com/google/gerrit/entities/PermissionTest.java
index 2915f79..3175671 100644
--- a/javatests/com/google/gerrit/entities/PermissionTest.java
+++ b/javatests/com/google/gerrit/entities/PermissionTest.java
@@ -34,9 +34,9 @@
     assertThat(Permission.isPermission(Permission.ABANDON)).isTrue();
     assertThat(Permission.isPermission("no-permission")).isFalse();
 
-    assertThat(Permission.isPermission(Permission.LABEL + "Code-Review")).isTrue();
-    assertThat(Permission.isPermission(Permission.LABEL_AS + "Code-Review")).isTrue();
-    assertThat(Permission.isPermission("Code-Review")).isFalse();
+    assertThat(Permission.isPermission(Permission.LABEL + LabelId.CODE_REVIEW)).isTrue();
+    assertThat(Permission.isPermission(Permission.LABEL_AS + LabelId.CODE_REVIEW)).isTrue();
+    assertThat(Permission.isPermission(LabelId.CODE_REVIEW)).isFalse();
   }
 
   @Test
@@ -44,9 +44,9 @@
     assertThat(Permission.hasRange(Permission.ABANDON)).isFalse();
     assertThat(Permission.hasRange("no-permission")).isFalse();
 
-    assertThat(Permission.hasRange(Permission.LABEL + "Code-Review")).isTrue();
-    assertThat(Permission.hasRange(Permission.LABEL_AS + "Code-Review")).isTrue();
-    assertThat(Permission.hasRange("Code-Review")).isFalse();
+    assertThat(Permission.hasRange(Permission.LABEL + LabelId.CODE_REVIEW)).isTrue();
+    assertThat(Permission.hasRange(Permission.LABEL_AS + LabelId.CODE_REVIEW)).isTrue();
+    assertThat(Permission.hasRange(LabelId.CODE_REVIEW)).isFalse();
   }
 
   @Test
@@ -54,9 +54,9 @@
     assertThat(Permission.isLabel(Permission.ABANDON)).isFalse();
     assertThat(Permission.isLabel("no-permission")).isFalse();
 
-    assertThat(Permission.isLabel(Permission.LABEL + "Code-Review")).isTrue();
-    assertThat(Permission.isLabel(Permission.LABEL_AS + "Code-Review")).isFalse();
-    assertThat(Permission.isLabel("Code-Review")).isFalse();
+    assertThat(Permission.isLabel(Permission.LABEL + LabelId.CODE_REVIEW)).isTrue();
+    assertThat(Permission.isLabel(Permission.LABEL_AS + LabelId.CODE_REVIEW)).isFalse();
+    assertThat(Permission.isLabel(LabelId.CODE_REVIEW)).isFalse();
   }
 
   @Test
@@ -64,27 +64,30 @@
     assertThat(Permission.isLabelAs(Permission.ABANDON)).isFalse();
     assertThat(Permission.isLabelAs("no-permission")).isFalse();
 
-    assertThat(Permission.isLabelAs(Permission.LABEL + "Code-Review")).isFalse();
-    assertThat(Permission.isLabelAs(Permission.LABEL_AS + "Code-Review")).isTrue();
-    assertThat(Permission.isLabelAs("Code-Review")).isFalse();
+    assertThat(Permission.isLabelAs(Permission.LABEL + LabelId.CODE_REVIEW)).isFalse();
+    assertThat(Permission.isLabelAs(Permission.LABEL_AS + LabelId.CODE_REVIEW)).isTrue();
+    assertThat(Permission.isLabelAs(LabelId.CODE_REVIEW)).isFalse();
   }
 
   @Test
   public void forLabel() {
-    assertThat(Permission.forLabel("Code-Review")).isEqualTo(Permission.LABEL + "Code-Review");
+    assertThat(Permission.forLabel(LabelId.CODE_REVIEW))
+        .isEqualTo(Permission.LABEL + LabelId.CODE_REVIEW);
   }
 
   @Test
   public void forLabelAs() {
-    assertThat(Permission.forLabelAs("Code-Review")).isEqualTo(Permission.LABEL_AS + "Code-Review");
+    assertThat(Permission.forLabelAs(LabelId.CODE_REVIEW))
+        .isEqualTo(Permission.LABEL_AS + LabelId.CODE_REVIEW);
   }
 
   @Test
   public void extractLabel() {
-    assertThat(Permission.extractLabel(Permission.LABEL + "Code-Review")).isEqualTo("Code-Review");
-    assertThat(Permission.extractLabel(Permission.LABEL_AS + "Code-Review"))
-        .isEqualTo("Code-Review");
-    assertThat(Permission.extractLabel("Code-Review")).isNull();
+    assertThat(Permission.extractLabel(Permission.LABEL + LabelId.CODE_REVIEW))
+        .isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(Permission.extractLabel(Permission.LABEL_AS + LabelId.CODE_REVIEW))
+        .isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(Permission.extractLabel(LabelId.CODE_REVIEW)).isNull();
     assertThat(Permission.extractLabel(Permission.ABANDON)).isNull();
   }
 
@@ -92,17 +95,23 @@
   public void canBeOnAllProjects() {
     assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.ABANDON)).isTrue();
     assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.OWNER)).isFalse();
-    assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.LABEL + "Code-Review"))
+    assertThat(
+            Permission.canBeOnAllProjects(
+                AccessSection.ALL, Permission.LABEL + LabelId.CODE_REVIEW))
         .isTrue();
     assertThat(
-            Permission.canBeOnAllProjects(AccessSection.ALL, Permission.LABEL_AS + "Code-Review"))
+            Permission.canBeOnAllProjects(
+                AccessSection.ALL, Permission.LABEL_AS + LabelId.CODE_REVIEW))
         .isTrue();
 
     assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.ABANDON)).isTrue();
     assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.OWNER)).isTrue();
-    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.LABEL + "Code-Review"))
+    assertThat(
+            Permission.canBeOnAllProjects("refs/heads/*", Permission.LABEL + LabelId.CODE_REVIEW))
         .isTrue();
-    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.LABEL_AS + "Code-Review"))
+    assertThat(
+            Permission.canBeOnAllProjects(
+                "refs/heads/*", Permission.LABEL_AS + LabelId.CODE_REVIEW))
         .isTrue();
   }
 
@@ -113,11 +122,11 @@
 
   @Test
   public void getLabel() {
-    assertThat(Permission.create(Permission.LABEL + "Code-Review").getLabel())
-        .isEqualTo("Code-Review");
-    assertThat(Permission.create(Permission.LABEL_AS + "Code-Review").getLabel())
-        .isEqualTo("Code-Review");
-    assertThat(Permission.create("Code-Review").getLabel()).isNull();
+    assertThat(Permission.create(Permission.LABEL + LabelId.CODE_REVIEW).getLabel())
+        .isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(Permission.create(Permission.LABEL_AS + LabelId.CODE_REVIEW).getLabel())
+        .isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(Permission.create(LabelId.CODE_REVIEW).getLabel()).isNull();
     assertThat(Permission.create(Permission.ABANDON).getLabel()).isNull();
   }
 
diff --git a/javatests/com/google/gerrit/entities/converter/AccountIdProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/AccountIdProtoConverterTest.java
index 0e4fbc8..12045b1 100644
--- a/javatests/com/google/gerrit/entities/converter/AccountIdProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/AccountIdProtoConverterTest.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import org.junit.Test;
 
 public class AccountIdProtoConverterTest {
@@ -48,17 +47,6 @@
     assertThat(convertedAccountId).isEqualTo(accountId);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.Account_Id proto = Entities.Account_Id.newBuilder().setId(24).build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.Account_Id> parser = accountIdProtoConverter.getParser();
-    Entities.Account_Id parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methodsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/BranchNameKeyProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/BranchNameKeyProtoConverterTest.java
index 0a73db8..7073fa7 100644
--- a/javatests/com/google/gerrit/entities/converter/BranchNameKeyProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/BranchNameKeyProtoConverterTest.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import org.junit.Test;
 
@@ -55,21 +54,6 @@
     assertThat(convertedNameKey).isEqualTo(nameKey);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.Branch_NameKey proto =
-        Entities.Branch_NameKey.newBuilder()
-            .setProject(Entities.Project_NameKey.newBuilder().setName("project 1"))
-            .setBranch("branch 36")
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.Branch_NameKey> parser = branchNameKeyProtoConverter.getParser();
-    Entities.Branch_NameKey parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methodsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeIdProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeIdProtoConverterTest.java
index 12f3f33..fe71c42 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeIdProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeIdProtoConverterTest.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import org.junit.Test;
 
 public class ChangeIdProtoConverterTest {
@@ -48,17 +47,6 @@
     assertThat(convertedChangeId).isEqualTo(changeId);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.Change_Id proto = Entities.Change_Id.newBuilder().setId(94).build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.Change_Id> parser = changeIdProtoConverter.getParser();
-    Entities.Change_Id parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methodsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeKeyProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeKeyProtoConverterTest.java
index e9080b3..745c90c 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeKeyProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeKeyProtoConverterTest.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import org.junit.Test;
 
 public class ChangeKeyProtoConverterTest {
@@ -48,17 +47,6 @@
     assertThat(convertedChangeKey).isEqualTo(changeKey);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.Change_Key proto = Entities.Change_Key.newBuilder().setId("change 36").build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.Change_Key> parser = changeKeyProtoConverter.getParser();
-    Entities.Change_Key parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methodsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverterTest.java
index 72ce896..98329d2 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverterTest.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import org.junit.Test;
 
@@ -55,21 +54,6 @@
     assertThat(convertedMessageKey).isEqualTo(messageKey);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.ChangeMessage_Key proto =
-        Entities.ChangeMessage_Key.newBuilder()
-            .setChangeId(Entities.Change_Id.newBuilder().setId(704))
-            .setUuid("aabbcc")
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.ChangeMessage_Key> parser = messageKeyProtoConverter.getParser();
-    Entities.ChangeMessage_Key parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methodsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
index 933ffb4..b185558 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
 import org.junit.Test;
@@ -174,23 +173,6 @@
     assertThat(convertedChangeMessage).isEqualTo(changeMessage);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.ChangeMessage proto =
-        Entities.ChangeMessage.newBuilder()
-            .setKey(
-                Entities.ChangeMessage_Key.newBuilder()
-                    .setChangeId(Entities.Change_Id.newBuilder().setId(543))
-                    .setUuid("change-message-21"))
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.ChangeMessage> parser = changeMessageProtoConverter.getParser();
-    Entities.ChangeMessage parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void fieldsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
index bc669cc..ae8e06d 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
 import org.junit.Test;
@@ -277,40 +276,6 @@
     assertThat(change.getLastUpdatedOn()).isEqualTo(new Timestamp(987654L));
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.Change proto =
-        Entities.Change.newBuilder()
-            .setChangeId(Entities.Change_Id.newBuilder().setId(14))
-            .setChangeKey(Entities.Change_Key.newBuilder().setId("change 1"))
-            .setRowVersion(0)
-            .setCreatedOn(987654L)
-            .setLastUpdatedOn(1234567L)
-            .setOwnerAccountId(Entities.Account_Id.newBuilder().setId(35))
-            .setDest(
-                Entities.Branch_NameKey.newBuilder()
-                    .setProject(Entities.Project_NameKey.newBuilder().setName("project 67"))
-                    .setBranch("branch 74"))
-            .setStatus(Change.STATUS_MERGED)
-            .setCurrentPatchSetId(23)
-            .setSubject("subject XYZ")
-            .setTopic("my topic")
-            .setOriginalSubject("original subject ABC")
-            .setSubmissionId("submission ID 234")
-            .setAssignee(Entities.Account_Id.newBuilder().setId(100001))
-            .setIsPrivate(true)
-            .setWorkInProgress(true)
-            .setReviewStarted(true)
-            .setRevertOf(Entities.Change_Id.newBuilder().setId(180))
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.Change> parser = changeProtoConverter.getParser();
-    Entities.Change parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void fieldsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/LabelIdProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/LabelIdProtoConverterTest.java
index 88b9fb6..6237ac0 100644
--- a/javatests/com/google/gerrit/entities/converter/LabelIdProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/LabelIdProtoConverterTest.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import org.junit.Test;
 
 public class LabelIdProtoConverterTest {
@@ -48,17 +47,6 @@
     assertThat(convertedLabelId).isEqualTo(labelId);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.LabelId proto = Entities.LabelId.newBuilder().setId("label-23").build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.LabelId> parser = labelIdProtoConverter.getParser();
-    Entities.LabelId parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methodsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/ObjectIdProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ObjectIdProtoConverterTest.java
index 8408b69..447c47f 100644
--- a/javatests/com/google/gerrit/entities/converter/ObjectIdProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ObjectIdProtoConverterTest.java
@@ -20,7 +20,6 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
@@ -48,18 +47,6 @@
     assertThat(convertedObjectId).isEqualTo(objectId);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.ObjectId proto =
-        Entities.ObjectId.newBuilder().setName("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef").build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.ObjectId> parser = objectIdProtoConverter.getParser();
-    Entities.ObjectId parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void fieldsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverterTest.java
index 11aac0d..be55561 100644
--- a/javatests/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverterTest.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import org.junit.Test;
 
@@ -65,25 +64,6 @@
     assertThat(convertedKey).isEqualTo(key);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.PatchSetApproval_Key proto =
-        Entities.PatchSetApproval_Key.newBuilder()
-            .setPatchSetId(
-                Entities.PatchSet_Id.newBuilder()
-                    .setChangeId(Entities.Change_Id.newBuilder().setId(42))
-                    .setId(14))
-            .setAccountId(Entities.Account_Id.newBuilder().setId(100013))
-            .setLabelId(Entities.LabelId.newBuilder().setId("label-8"))
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.PatchSetApproval_Key> parser = protoConverter.getParser();
-    Entities.PatchSetApproval_Key parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methodsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java
index bca5eea..bf39ff8 100644
--- a/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java
@@ -27,7 +27,6 @@
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.inject.TypeLiteral;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
 import java.util.Date;
@@ -165,29 +164,6 @@
     assertThat(patchSetApproval.postSubmit()).isEqualTo(false);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.PatchSetApproval proto =
-        Entities.PatchSetApproval.newBuilder()
-            .setKey(
-                Entities.PatchSetApproval_Key.newBuilder()
-                    .setPatchSetId(
-                        Entities.PatchSet_Id.newBuilder()
-                            .setChangeId(Entities.Change_Id.newBuilder().setId(42))
-                            .setId(14))
-                    .setAccountId(Entities.Account_Id.newBuilder().setId(100013))
-                    .setLabelId(Entities.LabelId.newBuilder().setId("label-8")))
-            .setValue(456)
-            .setGranted(987654L)
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.PatchSetApproval> parser = protoConverter.getParser();
-    Entities.PatchSetApproval parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void fieldsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/PatchSetIdProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetIdProtoConverterTest.java
index 530b431..c858582 100644
--- a/javatests/com/google/gerrit/entities/converter/PatchSetIdProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetIdProtoConverterTest.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import org.junit.Test;
 
@@ -55,21 +54,6 @@
     assertThat(convertedPatchSetId).isEqualTo(patchSetId);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.PatchSet_Id proto =
-        Entities.PatchSet_Id.newBuilder()
-            .setChangeId(Entities.Change_Id.newBuilder().setId(103))
-            .setId(73)
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.PatchSet_Id> parser = patchSetIdProtoConverter.getParser();
-    Entities.PatchSet_Id parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methodsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
index 2519e75..efeb24f 100644
--- a/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.inject.TypeLiteral;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
 import java.util.Optional;
@@ -148,23 +147,6 @@
                 .build());
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.PatchSet proto =
-        Entities.PatchSet.newBuilder()
-            .setId(
-                Entities.PatchSet_Id.newBuilder()
-                    .setChangeId(Entities.Change_Id.newBuilder().setId(103))
-                    .setId(73))
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.PatchSet> parser = patchSetProtoConverter.getParser();
-    Entities.PatchSet parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void fieldsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverterTest.java
index 2f693e6..2fa89a5 100644
--- a/javatests/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverterTest.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import org.junit.Test;
 
 public class ProjectNameKeyProtoConverterTest {
@@ -50,18 +49,6 @@
     assertThat(convertedNameKey).isEqualTo(nameKey);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.Project_NameKey proto =
-        Entities.Project_NameKey.newBuilder().setName("project 36").build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.Project_NameKey> parser = projectNameKeyProtoConverter.getParser();
-    Entities.Project_NameKey parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void fieldsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
index ba9475f..634231f 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
@@ -15,19 +15,24 @@
 package com.google.gerrit.httpd.raw;
 
 import static com.google.common.truth.Truth.assertThat;
-import static java.util.stream.Collectors.joining;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.accounts.Accounts;
 import com.google.gerrit.extensions.api.config.Config;
 import com.google.gerrit.extensions.api.config.Server;
 import com.google.gerrit.extensions.common.ServerInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.experiments.ConfigExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
 import com.google.gerrit.util.http.testutil.FakeHttpServletResponse;
+import java.util.ArrayList;
+import java.util.List;
 import org.junit.Test;
 
 public class IndexServletTest {
@@ -55,14 +60,19 @@
     String testCdnPath = "bar-cdn";
     String testFaviconURL = "zaz-url";
 
-    String disabledDefault = IndexHtmlUtil.DEFAULT_EXPERIMENTS.asList().get(0);
+    // Pick any known experiment enabled by default;
+    String disabledDefault = ExperimentFeaturesConstants.UI_FEATURE_PATCHSET_COMMENTS;
+    assertThat(ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES).contains(disabledDefault);
+
     org.eclipse.jgit.lib.Config serverConfig = new org.eclipse.jgit.lib.Config();
     serverConfig.setStringList(
         "experiments", null, "enabled", ImmutableList.of("NewFeature", "DisabledFeature"));
     serverConfig.setStringList(
         "experiments", null, "disabled", ImmutableList.of("DisabledFeature", disabledDefault));
+    ExperimentFeatures experimentFeatures = new ConfigExperimentFeatures(serverConfig);
     IndexServlet servlet =
-        new IndexServlet(testCanonicalUrl, testCdnPath, testFaviconURL, gerritApi, serverConfig);
+        new IndexServlet(
+            testCanonicalUrl, testCdnPath, testFaviconURL, gerritApi, experimentFeatures);
 
     FakeHttpServletResponse response = new FakeHttpServletResponse();
 
@@ -85,14 +95,17 @@
                 + "\\x22\\/config\\/server\\/info\\x22: \\x7b\\x22default_theme\\x22:"
                 + "\\x22my-default-theme\\x22\\x7d, \\x22\\/config\\/server\\/top-menus\\x22: "
                 + "\\x5b\\x5d\\x7d');");
-    String enabledDefaults =
-        IndexHtmlUtil.DEFAULT_EXPERIMENTS.stream()
+    ImmutableSet<String> enabledDefaults =
+        ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES.stream()
             .filter(e -> !e.equals(disabledDefault))
-            .collect(joining("\\x22,"));
+            .collect(ImmutableSet.toImmutableSet());
+    List<String> expectedEnabled = new ArrayList<>();
+    expectedEnabled.add("NewFeature");
+    expectedEnabled.addAll(enabledDefaults);
     assertThat(output)
         .contains(
-            "window.ENABLED_EXPERIMENTS = JSON.parse('\\x5b\\x22NewFeature\\x22,\\x22"
-                + enabledDefaults
+            "window.ENABLED_EXPERIMENTS = JSON.parse('\\x5b\\x22"
+                + String.join("\\x22,", expectedEnabled)
                 + "\\x22\\x5d');</script>");
   }
 }
diff --git a/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java b/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
index 76ce956..f9df375 100644
--- a/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
+++ b/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
@@ -42,7 +42,6 @@
 import java.io.File;
 import java.net.InetSocketAddress;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
 import org.junit.BeforeClass;
 import org.junit.Test;
 
@@ -91,18 +90,13 @@
       projectOperations
           .project(project)
           .forUpdate()
-          .add(deny(Permission.READ).ref("refs/*").group(SystemGroupBackend.ANONYMOUS_USERS))
+          .add(deny(Permission.READ).ref("refs/heads/*").group(SystemGroupBackend.ANONYMOUS_USERS))
           .add(
               allow(Permission.READ)
                   .ref("refs/heads/master")
                   .group(SystemGroupBackend.REGISTERED_USERS))
           .update();
 
-      // Set protocol.version=2 in target repository
-      execute(
-          ImmutableList.of("git", "config", "protocol.version", "2"),
-          sitePaths.site_path.resolve("git").resolve(project.get() + Constants.DOT_GIT).toFile());
-
       // Retrieve HTTP url
       String url = config.getString("gerrit", null, "canonicalweburl");
       String urlDestinationTemplate =
@@ -217,15 +211,6 @@
       Project.NameKey allRefsVisibleProject = Project.nameKey("all-refs-visible");
       gApi.projects().create(allRefsVisibleProject.get());
 
-      // Set protocol.version=2 in target repository
-      execute(
-          ImmutableList.of("git", "config", "protocol.version", "2"),
-          sitePaths
-              .site_path
-              .resolve("git")
-              .resolve(allRefsVisibleProject.get() + Constants.DOT_GIT)
-              .toFile());
-
       // Set up project permission to allow reading all refs
       projectOperations
           .project(allRefsVisibleProject)
@@ -280,15 +265,6 @@
       Project.NameKey privateProject = Project.nameKey("private-project");
       gApi.projects().create(privateProject.get());
 
-      // Set protocol.version=2 in target repository
-      execute(
-          ImmutableList.of("git", "config", "protocol.version", "2"),
-          sitePaths
-              .site_path
-              .resolve("git")
-              .resolve(privateProject.get() + Constants.DOT_GIT)
-              .toFile());
-
       // Disallow general read permissions for anonymous users
       projectOperations
           .project(allProjectsName)
diff --git a/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java b/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java
index 66b02ff..c720905 100644
--- a/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java
+++ b/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java
@@ -168,7 +168,11 @@
         .add("archive")
         .add("-f=" + format)
         .add("--prefix=" + commit + "/")
+        // --remote makes git execute "git archive" on the server through SSH.
+        // The Gerrit/JGit version of the command understands the --compression-level
+        // argument below.
         .add("--remote=" + sshDestination)
+        .add("--compression-level=1") // set to 1 to reduce the memory footprint
         .add(commit)
         .add(FILE_NAME)
         .build();
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index 248c7d1..7ab7ae9 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -49,6 +49,7 @@
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/mail",
         "//java/com/google/gerrit/metrics",
+        "//java/com/google/gerrit/proto",
         "//java/com/google/gerrit/proto/testing",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/account/externalids/testing",
@@ -73,6 +74,7 @@
         "//lib:jgit",
         "//lib:jgit-junit",
         "//lib:protobuf",
+        "//lib:soy",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/flogger:api",
@@ -82,5 +84,6 @@
         "//lib/truth:truth-java8-extension",
         "//lib/truth:truth-proto-extension",
         "//proto:cache_java_proto",
+        "//proto:entities_java_proto",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java
new file mode 100644
index 0000000..643c7b7
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java
@@ -0,0 +1,42 @@
+package com.google.gerrit.server.cache.serialize;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.comment.CommentContextCacheImpl.CommentContextSerializer.INSTANCE;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.CommentContext;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.comment.CommentContextKey;
+import org.junit.Test;
+
+public class CommentContextSerializerTest {
+  @Test
+  public void roundTripValue() {
+    CommentContext commentContext =
+        CommentContext.create(ImmutableMap.of(1, "line_1", 2, "line_2"));
+
+    byte[] serialized = INSTANCE.serialize(commentContext);
+    CommentContext deserialized = INSTANCE.deserialize(serialized);
+
+    assertThat(commentContext).isEqualTo(deserialized);
+  }
+
+  @Test
+  public void roundTripKey() {
+    Project.NameKey proj = Project.NameKey.parse("project");
+    Change.Id changeId = Change.Id.tryParse("1234").get();
+
+    CommentContextKey k =
+        CommentContextKey.builder()
+            .project(proj)
+            .changeId(changeId)
+            .id("commentId")
+            .path("pathHash")
+            .patchset(1)
+            .contextPadding(3)
+            .build();
+    byte[] serialized = CommentContextKey.Serializer.INSTANCE.serialize(k);
+    assertThat(k).isEqualTo(CommentContextKey.Serializer.INSTANCE.deserialize(serialized));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD b/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD
index 7fe73d5..a8158fc 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD
@@ -4,13 +4,10 @@
     name = "tests",
     srcs = glob(["*.java"]),
     deps = [
-        "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
-        "//java/com/google/gerrit/server/cache/serialize",
+        "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/cache/serialize/entities",
-        "//java/com/google/gerrit/server/cache/testing",
-        "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
         "//lib:jgit",
         "//lib:protobuf",
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffCacheKeySerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffCacheKeySerializerTest.java
new file mode 100644
index 0000000..ecab07d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffCacheKeySerializerTest.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.server.patch.filediff.FileDiffCacheKey;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl.DiffAlgorithm;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+/** Tests the protobuf serializer for the {@link FileDiffCacheKey}. */
+public class FileDiffCacheKeySerializerTest {
+  private static final ObjectId COMMIT_ID_1 =
+      ObjectId.fromString("123e9fa8a286255ac7d5ba11b598892735758391");
+  private static final ObjectId COMMIT_ID_2 =
+      ObjectId.fromString("d07a03a9818c120301cb5b4a969b035479400b5f");
+
+  @Test
+  public void roundTrip() {
+    FileDiffCacheKey key =
+        FileDiffCacheKey.builder()
+            .project(Project.nameKey("project/x"))
+            .oldCommit(COMMIT_ID_1)
+            .newCommit(COMMIT_ID_2)
+            .newFilePath("some_file.txt")
+            .renameScore(65)
+            .diffAlgorithm(DiffAlgorithm.HISTOGRAM)
+            .whitespace(Whitespace.IGNORE_ALL)
+            .build();
+
+    byte[] serialized = FileDiffCacheKey.Serializer.INSTANCE.serialize(key);
+
+    assertThat(FileDiffCacheKey.Serializer.INSTANCE.deserialize(serialized)).isEqualTo(key);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java
new file mode 100644
index 0000000..17fd959
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.Patch.PatchType;
+import com.google.gerrit.server.patch.ComparisonType;
+import com.google.gerrit.server.patch.filediff.Edit;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.patch.filediff.TaggedEdit;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class FileDiffOutputSerializerTest {
+  @Test
+  public void roundTrip() {
+    ImmutableList<TaggedEdit> edits =
+        ImmutableList.of(
+            TaggedEdit.create(Edit.create(1, 5, 3, 4), true),
+            TaggedEdit.create(Edit.create(21, 30, 150, 158), false));
+
+    FileDiffOutput fileDiff =
+        FileDiffOutput.builder()
+            .oldCommitId(ObjectId.fromString("dd4d2a1498870ca5fe415b33f65d052d69d9eaf5"))
+            .newCommitId(ObjectId.fromString("0cfaab3f2ba76f71798da0a2651f41be8d45f842"))
+            .comparisonType(ComparisonType.againstOtherPatchSet())
+            .oldPath(Optional.of("old_file_path.txt"))
+            .newPath(Optional.empty())
+            .changeType(ChangeType.DELETED)
+            .patchType(Optional.of(PatchType.UNIFIED))
+            .size(23)
+            .sizeDelta(10)
+            .headerLines(ImmutableList.of("header line 1", "header line 2"))
+            .edits(edits)
+            .build();
+
+    byte[] serialized = FileDiffOutput.Serializer.INSTANCE.serialize(fileDiff);
+    assertThat(FileDiffOutput.Serializer.INSTANCE.deserialize(serialized)).isEqualTo(fileDiff);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffKeySerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffKeySerializerTest.java
new file mode 100644
index 0000000..12d8d00
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffKeySerializerTest.java
@@ -0,0 +1,49 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl.DiffAlgorithm;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheKey;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class GitFileDiffKeySerializerTest {
+  private static final ObjectId TREE_ID_1 =
+      ObjectId.fromString("123e9fa8a286255ac7d5ba11b598892735758391");
+  private static final ObjectId TREE_ID_2 =
+      ObjectId.fromString("d07a03a9818c120301cb5b4a969b035479400b5f");
+
+  @Test
+  public void roundTrip() {
+    GitFileDiffCacheKey key =
+        GitFileDiffCacheKey.builder()
+            .project(Project.nameKey("project/x"))
+            .oldTree(TREE_ID_1)
+            .newTree(TREE_ID_2)
+            .newFilePath("some_file.txt")
+            .renameScore(65)
+            .diffAlgorithm(DiffAlgorithm.HISTOGRAM)
+            .whitespace(Whitespace.IGNORE_ALL)
+            .build();
+
+    byte[] serialized = GitFileDiffCacheKey.Serializer.INSTANCE.serialize(key);
+
+    assertThat(GitFileDiffCacheKey.Serializer.INSTANCE.deserialize(serialized)).isEqualTo(key);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffSerializerTest.java
new file mode 100644
index 0000000..93441a4
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffSerializerTest.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.Patch.FileMode;
+import com.google.gerrit.entities.Patch.PatchType;
+import com.google.gerrit.server.patch.filediff.Edit;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiff;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiff.Serializer;
+import java.util.Optional;
+import org.eclipse.jgit.lib.AbbreviatedObjectId;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class GitFileDiffSerializerTest {
+  private static final ObjectId OLD_ID =
+      ObjectId.fromString("123e9fa8a286255ac7d5ba11b598892735758391");
+  private static final ObjectId NEW_ID =
+      ObjectId.fromString("d07a03a9818c120301cb5b4a969b035479400b5f");
+
+  @Test
+  public void roundTrip() {
+    ImmutableList<Edit> edits =
+        ImmutableList.of(Edit.create(1, 5, 3, 4), Edit.create(21, 30, 150, 158));
+
+    GitFileDiff gitFileDiff =
+        GitFileDiff.builder()
+            .edits(edits)
+            .fileHeader("file_header")
+            .oldPath(Optional.of("old_file_path.txt"))
+            .newPath(Optional.empty())
+            .oldId(AbbreviatedObjectId.fromObjectId(OLD_ID))
+            .newId(AbbreviatedObjectId.fromObjectId(NEW_ID))
+            .changeType(ChangeType.DELETED)
+            .patchType(Optional.of(PatchType.UNIFIED))
+            .oldMode(Optional.of(FileMode.REGULAR_FILE))
+            .newMode(Optional.of(FileMode.REGULAR_FILE))
+            .build();
+
+    byte[] serialized = Serializer.INSTANCE.serialize(gitFileDiff);
+    assertThat(Serializer.INSTANCE.deserialize(serialized)).isEqualTo(gitFileDiff);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/GitModifiedFilesCacheKeySerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/GitModifiedFilesCacheKeySerializerTest.java
new file mode 100644
index 0000000..caf1fbb
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/GitModifiedFilesCacheKeySerializerTest.java
@@ -0,0 +1,43 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheKey;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheKey.Serializer;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class GitModifiedFilesCacheKeySerializerTest {
+  private static final ObjectId TREE_ID_1 =
+      ObjectId.fromString("123e9fa8a286255ac7d5ba11b598892735758391");
+  private static final ObjectId TREE_ID_2 =
+      ObjectId.fromString("d07a03a9818c120301cb5b4a969b035479400b5f");
+
+  @Test
+  public void roundTrip() {
+    GitModifiedFilesCacheKey key =
+        GitModifiedFilesCacheKey.builder()
+            .project(Project.NameKey.parse("Project/X"))
+            .aTree(TREE_ID_1)
+            .bTree(TREE_ID_2)
+            .renameScore(65)
+            .build();
+    byte[] serialized = Serializer.INSTANCE.serialize(key);
+    assertThat(Serializer.INSTANCE.deserialize(serialized)).isEqualTo(key);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
index a82fdb9..ad460cd 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
@@ -38,6 +38,8 @@
           .setCopyAnyScore(!LabelType.DEF_COPY_ANY_SCORE)
           .setCopyMaxScore(!LabelType.DEF_COPY_MAX_SCORE)
           .setCopyMinScore(!LabelType.DEF_COPY_MIN_SCORE)
+          .setCopyAllScoresIfListOfFilesDidNotChange(
+              !LabelType.DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE)
           .setCopyAllScoresOnMergeFirstParentUpdate(
               !LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE)
           .setCopyAllScoresOnTrivialRebase(!LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE)
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesCacheKeySerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesCacheKeySerializerTest.java
new file mode 100644
index 0000000..b39ba57
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesCacheKeySerializerTest.java
@@ -0,0 +1,42 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.patch.diff.ModifiedFilesCacheKey;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class ModifiedFilesCacheKeySerializerTest {
+  private static final ObjectId COMMIT_ID_1 =
+      ObjectId.fromString("123e9fa8a286255ac7d5ba11b598892735758391");
+  private static final ObjectId COMMIT_ID_2 =
+      ObjectId.fromString("d07a03a9818c120301cb5b4a969b035479400b5f");
+
+  @Test
+  public void roundTrip() {
+    ModifiedFilesCacheKey key =
+        ModifiedFilesCacheKey.builder()
+            .project(Project.NameKey.parse("Project/X"))
+            .aCommit(COMMIT_ID_1)
+            .bCommit(COMMIT_ID_2)
+            .renameScore(65)
+            .build();
+    byte[] serialized = ModifiedFilesCacheKey.Serializer.INSTANCE.serialize(key);
+    assertThat(ModifiedFilesCacheKey.Serializer.INSTANCE.deserialize(serialized)).isEqualTo(key);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesSerializerTest.java
new file mode 100644
index 0000000..bff0c5d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesSerializerTest.java
@@ -0,0 +1,56 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheImpl.ValueSerializer;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
+import java.util.Optional;
+import org.junit.Test;
+
+public class ModifiedFilesSerializerTest {
+  @Test
+  public void roundTrip() {
+    ImmutableList.Builder<ModifiedFile> builder = ImmutableList.builder();
+
+    builder.add(
+        ModifiedFile.builder()
+            .changeType(ChangeType.DELETED)
+            .oldPath(Optional.of("file_1.txt"))
+            .newPath(Optional.of("file_2.txt"))
+            .build());
+    builder.add(
+        ModifiedFile.builder()
+            .changeType(ChangeType.ADDED)
+            .oldPath(Optional.empty())
+            .newPath(Optional.of("file_3.txt"))
+            .build());
+
+    // Note: the default value for strings in protocol buffers is the empty string, hence the
+    // serializer will not be able to differentiate between an empty optional and an optional
+    // with an empty string, i.e. if we serialize an optional with an empty string, the deserialized
+    // object will be an empty optional. That should not be problematic in this case because file
+    // paths cannot be empty anyway.
+
+    ImmutableList<ModifiedFile> modifiedFiles = builder.build();
+
+    byte[] serialized = ValueSerializer.INSTANCE.serialize(modifiedFiles);
+
+    assertThat(ValueSerializer.INSTANCE.deserialize(serialized)).isEqualTo(modifiedFiles);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
index 683f5a6..cd28ac4 100644
--- a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
+++ b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
@@ -109,7 +109,8 @@
           });
     }
     LabelType lt =
-        label("Verified", value(1, "Verified"), value(0, "No score"), value(-1, "Fails"));
+        label(
+            LabelId.VERIFIED, value(1, LabelId.VERIFIED), value(0, "No score"), value(-1, "Fails"));
     pc.upsertLabelType(lt);
     save(pc);
   }
@@ -137,12 +138,16 @@
   public void noNormalizeByPermission() throws Exception {
     projectOperations
         .allProjectsForUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-1, 1))
-        .add(allowLabel("Verified").ref("refs/heads/*").group(REGISTERED_USERS).range(-1, 1))
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .add(allowLabel(LabelId.VERIFIED).ref("refs/heads/*").group(REGISTERED_USERS).range(-1, 1))
         .update();
 
-    PatchSetApproval cr = psa(userId, "Code-Review", 2);
-    PatchSetApproval v = psa(userId, "Verified", 1);
+    PatchSetApproval cr = psa(userId, LabelId.CODE_REVIEW, 2);
+    PatchSetApproval v = psa(userId, LabelId.VERIFIED, 1);
     assertEquals(Result.create(list(cr, v), list(), list()), norm.normalize(notes, list(cr, v)));
   }
 
@@ -150,12 +155,16 @@
   public void normalizeByType() throws Exception {
     projectOperations
         .allProjectsForUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-5, 5))
-        .add(allowLabel("Verified").ref("refs/heads/*").group(REGISTERED_USERS).range(-5, 5))
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-5, 5))
+        .add(allowLabel(LabelId.VERIFIED).ref("refs/heads/*").group(REGISTERED_USERS).range(-5, 5))
         .update();
 
-    PatchSetApproval cr = psa(userId, "Code-Review", 5);
-    PatchSetApproval v = psa(userId, "Verified", 5);
+    PatchSetApproval cr = psa(userId, LabelId.CODE_REVIEW, 5);
+    PatchSetApproval v = psa(userId, LabelId.VERIFIED, 5);
     assertEquals(
         Result.create(list(), list(copy(cr, 2), copy(v, 1)), list()),
         norm.normalize(notes, list(cr, v)));
@@ -163,8 +172,8 @@
 
   @Test
   public void emptyPermissionRangeKeepsResult() throws Exception {
-    PatchSetApproval cr = psa(userId, "Code-Review", 1);
-    PatchSetApproval v = psa(userId, "Verified", 1);
+    PatchSetApproval cr = psa(userId, LabelId.CODE_REVIEW, 1);
+    PatchSetApproval v = psa(userId, LabelId.VERIFIED, 1);
     assertEquals(Result.create(list(cr, v), list(), list()), norm.normalize(notes, list(cr, v)));
   }
 
@@ -172,11 +181,15 @@
   public void explicitZeroVoteOnNonEmptyRangeIsPresent() throws Exception {
     projectOperations
         .allProjectsForUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-1, 1))
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
         .update();
 
-    PatchSetApproval cr = psa(userId, "Code-Review", 0);
-    PatchSetApproval v = psa(userId, "Verified", 0);
+    PatchSetApproval cr = psa(userId, LabelId.CODE_REVIEW, 0);
+    PatchSetApproval v = psa(userId, LabelId.VERIFIED, 0);
     assertEquals(Result.create(list(cr, v), list(), list()), norm.normalize(notes, list(cr, v)));
   }
 
diff --git a/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java b/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java
index 698acd8..7eb6bc7 100644
--- a/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java
+++ b/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java
@@ -24,6 +24,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.RefNames;
 import java.util.Map;
 import org.eclipse.jgit.lib.ObjectId;
@@ -60,17 +61,18 @@
   }
 
   @Test
-  public void noCache_tipsFromObjectIdDelegatesToRefDbAndFiltersByPrefix() throws Exception {
+  public void noCache_tipsFromObjectIdDelegatesToRefDb() throws Exception {
     Ref refBla = newRef("refs/bla", "badc0feebadc0feebadc0feebadc0feebadc0fee");
-    Ref refheads = newRef(RefNames.REFS_HEADS, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    String patchSetRef = RefNames.REFS_CHANGES + "01/1/1";
+    Ref patchSet = newRef(patchSetRef, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
 
     RefDatabase mockRefDb = mock(RefDatabase.class);
     ReceivePackRefCache cache = ReceivePackRefCache.noCache(mockRefDb);
     when(mockRefDb.getTipsWithSha1(ObjectId.zeroId()))
-        .thenReturn(ImmutableSet.of(refBla, refheads));
+        .thenReturn(ImmutableSet.of(refBla, patchSet));
 
-    assertThat(cache.tipsFromObjectId(ObjectId.zeroId(), RefNames.REFS_HEADS))
-        .containsExactly(refheads);
+    assertThat(cache.patchSetIdsFromObjectId(ObjectId.zeroId()))
+        .containsExactly(PatchSet.Id.fromRef(patchSetRef));
     verify(mockRefDb).getTipsWithSha1(ObjectId.zeroId());
     verifyNoMoreInteractions(mockRefDb);
   }
@@ -107,25 +109,14 @@
   }
 
   @Test
-  public void advertisedRefs_tipsFromObjectIdWithNoPrefix() throws Exception {
+  public void advertisedRefs_patchSetIdsFromObjectId() throws Exception {
     Map<String, Ref> refs = setupTwoChanges();
     ReceivePackRefCache cache = ReceivePackRefCache.withAdvertisedRefs(() -> refs);
 
     assertThat(
-            cache.tipsFromObjectId(
-                ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee"), null))
-        .containsExactly(refs.get("refs/changes/01/1/1"));
-  }
-
-  @Test
-  public void advertisedRefs_tipsFromObjectIdWithPrefix() throws Exception {
-    Map<String, Ref> refs = setupTwoChanges();
-    ReceivePackRefCache cache = ReceivePackRefCache.withAdvertisedRefs(() -> refs);
-
-    assertThat(
-            cache.tipsFromObjectId(
-                ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee"), "/refs/some"))
-        .isEmpty();
+            cache.patchSetIdsFromObjectId(
+                ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee")))
+        .containsExactly(PatchSet.Id.fromRef("refs/changes/01/1/1"));
   }
 
   private static Ref newRef(String name, String sha1) {
diff --git a/javatests/com/google/gerrit/server/group/db/BUILD b/javatests/com/google/gerrit/server/group/db/BUILD
index 3303338..9f9f459 100644
--- a/javatests/com/google/gerrit/server/group/db/BUILD
+++ b/javatests/com/google/gerrit/server/group/db/BUILD
@@ -6,7 +6,6 @@
     srcs = glob(["*.java"]),
     deps = [
         "//java/com/google/gerrit/common:annotations",
-        "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/common/data/testing:common-data-test-util",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/exceptions",
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java b/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
index f5d3bf7..e5b2ffb 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
@@ -107,6 +107,32 @@
   }
 
   @Test
+  public void threeLevelTreeWithMultipleSources() throws Exception {
+    Predicate<ChangeData> in = parse("-status:abandoned (foo:a OR file:b)");
+    Predicate<ChangeData> out = rewrite(in);
+    assertThat(out.getClass()).isSameInstanceAs(AndChangeSource.class);
+
+    Predicate<ChangeData> firstIndexedSubQuery = parse("-status:abandoned");
+
+    assertThat(out.getChild(0)).isEqualTo(query(firstIndexedSubQuery));
+
+    assertThat(out.getChild(1).getClass()).isSameInstanceAs(OrSource.class);
+    OrSource indexedSubTree = (OrSource) out.getChild(1);
+
+    Predicate<ChangeData> secondIndexedSubQuery = parse("foo:a OR file:b");
+    assertThat(indexedSubTree.getChildren())
+        .containsExactly(
+            query(secondIndexedSubQuery.getChild(1)), secondIndexedSubQuery.getChild(0))
+        .inOrder();
+
+    // Same at the assertions above, that were added for readability
+    assertThat(out.getChild(0)).isEqualTo(query(in.getChild(0)));
+    assertThat(indexedSubTree.getChildren())
+        .containsExactly(query(in.getChild(1).getChild(1)), in.getChild(1).getChild(0))
+        .inOrder();
+  }
+
+  @Test
   public void threeLevelTreeWithSomeIndexPredicates() throws Exception {
     Predicate<ChangeData> in = parse("-foo:a (file:b OR file:c)");
     Predicate<ChangeData> out = rewrite(in);
diff --git a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
index 733d784..8d019f3 100644
--- a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
+++ b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
@@ -24,8 +24,8 @@
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import org.eclipse.jgit.lib.Config;
@@ -76,7 +76,7 @@
       // Create a performance log record.
       TraceContext.newTimer("test").close();
 
-      SortedMap<String, SortedSet<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
+      Map<String, ? extends Set<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
       assertThat(tagMap.keySet()).containsExactly("foo");
       assertThat(tagMap.get("foo")).containsExactly("bar");
       assertForceLogging(true);
@@ -90,7 +90,7 @@
               () -> {
                 // Verify that the tags and force logging flag have been propagated to the new
                 // thread.
-                SortedMap<String, SortedSet<Object>> threadTagMap =
+                Map<String, ? extends Set<Object>> threadTagMap =
                     LoggingContext.getInstance().getTags().asMap();
                 expect.that(threadTagMap.keySet()).containsExactly("foo");
                 expect.that(threadTagMap.get("foo")).containsExactly("bar");
diff --git a/javatests/com/google/gerrit/server/logging/MutableTagsTest.java b/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
index f6f3b46..200c49d 100644
--- a/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
+++ b/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
@@ -21,8 +21,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
 import java.util.Map;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.Set;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -157,7 +156,7 @@
   }
 
   private void assertTags(ImmutableMap<String, ImmutableSet<String>> expectedTagMap) {
-    SortedMap<String, SortedSet<Object>> actualTagMap = tags.getTags().asMap();
+    Map<String, ? extends Set<Object>> actualTagMap = tags.getTags().asMap();
     assertThat(actualTagMap.keySet()).containsExactlyElementsIn(expectedTagMap.keySet());
     for (Map.Entry<String, ImmutableSet<String>> expectedEntry : expectedTagMap.entrySet()) {
       assertThat(actualTagMap.get(expectedEntry.getKey()))
diff --git a/javatests/com/google/gerrit/server/logging/TraceContextTest.java b/javatests/com/google/gerrit/server/logging/TraceContextTest.java
index 13f2035..6a3632d 100644
--- a/javatests/com/google/gerrit/server/logging/TraceContextTest.java
+++ b/javatests/com/google/gerrit/server/logging/TraceContextTest.java
@@ -21,8 +21,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.server.logging.TraceContext.TraceIdConsumer;
 import java.util.Map;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.Set;
 import org.junit.After;
 import org.junit.Test;
 
@@ -254,7 +253,7 @@
   }
 
   private void assertTags(ImmutableMap<String, ImmutableSet<String>> expectedTagMap) {
-    SortedMap<String, SortedSet<Object>> actualTagMap =
+    Map<String, ? extends Set<Object>> actualTagMap =
         LoggingContext.getInstance().getTags().asMap();
     assertThat(actualTagMap.keySet()).containsExactlyElementsIn(expectedTagMap.keySet());
     for (Map.Entry<String, ImmutableSet<String>> expectedEntry : expectedTagMap.entrySet()) {
diff --git a/javatests/com/google/gerrit/server/mail/send/MailSoySauceProviderTest.java b/javatests/com/google/gerrit/server/mail/send/MailSoySauceProviderTest.java
new file mode 100644
index 0000000..2ec5e4d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/mail/send/MailSoySauceProviderTest.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.template.soy.shared.SoyAstCache;
+import java.nio.file.Paths;
+import org.junit.Before;
+import org.junit.Test;
+
+public class MailSoySauceProviderTest {
+
+  private SitePaths sitePaths;
+  private DynamicSet<MailSoyTemplateProvider> set;
+
+  @Before
+  public void setUp() throws Exception {
+    sitePaths = new SitePaths(Paths.get("."));
+    set = new DynamicSet<>();
+  }
+
+  @Test
+  public void soyCompilation() {
+    MailSoySauceProvider provider =
+        new MailSoySauceProvider(
+            sitePaths,
+            new SoyAstCache(),
+            new PluginSetContext<>(set, PluginMetrics.DISABLED_INSTANCE));
+    assertThat(provider.get()).isNotNull(); // should not throw
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index dd3238f..78804a7 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -17,7 +17,6 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
 import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
-import static com.google.gerrit.server.notedb.ChangeNotesState.Serializer.toByteString;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
@@ -40,6 +39,8 @@
 import com.google.gerrit.entities.converter.ChangeMessageProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetProtoConverter;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
@@ -332,7 +333,8 @@
             .uploader(Account.id(2000))
             .createdOn(cols.createdOn())
             .build();
-    ByteString ps1Bytes = toByteString(ps1, PatchSetProtoConverter.INSTANCE);
+    Entities.PatchSet ps1Proto = PatchSetProtoConverter.INSTANCE.toProto(ps1);
+    ByteString ps1Bytes = Protos.toByteString(ps1Proto);
     assertThat(ps1Bytes.size()).isEqualTo(66);
 
     PatchSet ps2 =
@@ -342,7 +344,8 @@
             .uploader(Account.id(3000))
             .createdOn(cols.lastUpdatedOn())
             .build();
-    ByteString ps2Bytes = toByteString(ps2, PatchSetProtoConverter.INSTANCE);
+    Entities.PatchSet ps2Proto = PatchSetProtoConverter.INSTANCE.toProto(ps2);
+    ByteString ps2Bytes = Protos.toByteString(ps2Proto);
     assertThat(ps2Bytes.size()).isEqualTo(66);
     assertThat(ps2Bytes).isNotEqualTo(ps1Bytes);
 
@@ -352,8 +355,8 @@
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
             .setColumns(colsProto)
-            .addPatchSet(ps2Bytes)
-            .addPatchSet(ps1Bytes)
+            .addPatchSet(ps2Proto)
+            .addPatchSet(ps1Proto)
             .build());
   }
 
@@ -363,22 +366,23 @@
         PatchSetApproval.builder()
             .key(
                 PatchSetApproval.key(
-                    PatchSet.id(ID, 1), Account.id(2001), LabelId.create("Code-Review")))
+                    PatchSet.id(ID, 1), Account.id(2001), LabelId.create(LabelId.CODE_REVIEW)))
             .value(1)
             .granted(new Timestamp(1212L))
             .build();
-    ByteString a1Bytes = toByteString(a1, PatchSetApprovalProtoConverter.INSTANCE);
-    assertThat(a1Bytes.size()).isEqualTo(43);
+    Entities.PatchSetApproval psa1 = PatchSetApprovalProtoConverter.INSTANCE.toProto(a1);
+    ByteString a1Bytes = Protos.toByteString(psa1);
 
     PatchSetApproval a2 =
         PatchSetApproval.builder()
             .key(
                 PatchSetApproval.key(
-                    PatchSet.id(ID, 1), Account.id(2002), LabelId.create("Verified")))
+                    PatchSet.id(ID, 1), Account.id(2002), LabelId.create(LabelId.VERIFIED)))
             .value(-1)
             .granted(new Timestamp(3434L))
             .build();
-    ByteString a2Bytes = toByteString(a2, PatchSetApprovalProtoConverter.INSTANCE);
+    Entities.PatchSetApproval psa2 = PatchSetApprovalProtoConverter.INSTANCE.toProto(a2);
+    ByteString a2Bytes = Protos.toByteString(psa2);
     assertThat(a2Bytes.size()).isEqualTo(49);
     assertThat(a2Bytes).isNotEqualTo(a1Bytes);
 
@@ -390,8 +394,8 @@
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
             .setColumns(colsProto)
-            .addApproval(a2Bytes)
-            .addApproval(a1Bytes)
+            .addApproval(psa2)
+            .addApproval(psa1)
             .build());
   }
 
@@ -634,6 +638,39 @@
   }
 
   @Test
+  public void serializeAllAttentionSetUpdates() throws Exception {
+    assertRoundTrip(
+        newBuilder()
+            .allAttentionSetUpdates(
+                ImmutableList.of(
+                    AttentionSetUpdate.createFromRead(
+                        Instant.EPOCH.plusSeconds(23),
+                        Account.id(1000),
+                        AttentionSetUpdate.Operation.ADD,
+                        "reason 1"),
+                    AttentionSetUpdate.createFromRead(
+                        Instant.EPOCH.plusSeconds(42),
+                        Account.id(2000),
+                        AttentionSetUpdate.Operation.REMOVE,
+                        "reason 2")))
+            .build(),
+        newProtoBuilder()
+            .addAllAttentionSetUpdate(
+                AttentionSetUpdateProto.newBuilder()
+                    .setTimestampMillis(23_000) // epoch millis
+                    .setAccount(1000)
+                    .setOperation("ADD")
+                    .setReason("reason 1"))
+            .addAllAttentionSetUpdate(
+                AttentionSetUpdateProto.newBuilder()
+                    .setTimestampMillis(42_000) // epoch millis
+                    .setAccount(2000)
+                    .setOperation("REMOVE")
+                    .setReason("reason 2"))
+            .build());
+  }
+
+  @Test
   public void serializeAssigneeUpdates() throws Exception {
     assertRoundTrip(
         newBuilder()
@@ -689,7 +726,8 @@
             Account.id(1000),
             new Timestamp(1212L),
             PatchSet.id(ID, 1));
-    ByteString m1Bytes = toByteString(m1, ChangeMessageProtoConverter.INSTANCE);
+    Entities.ChangeMessage m1Proto = ChangeMessageProtoConverter.INSTANCE.toProto(m1);
+    ByteString m1Bytes = Protos.toByteString(m1Proto);
     assertThat(m1Bytes.size()).isEqualTo(35);
 
     ChangeMessage m2 =
@@ -698,7 +736,8 @@
             Account.id(2000),
             new Timestamp(3434L),
             PatchSet.id(ID, 2));
-    ByteString m2Bytes = toByteString(m2, ChangeMessageProtoConverter.INSTANCE);
+    Entities.ChangeMessage m2Proto = ChangeMessageProtoConverter.INSTANCE.toProto(m2);
+    ByteString m2Bytes = Protos.toByteString(m2Proto);
     assertThat(m2Bytes.size()).isEqualTo(35);
     assertThat(m2Bytes).isNotEqualTo(m1Bytes);
 
@@ -708,8 +747,8 @@
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
             .setColumns(colsProto)
-            .addChangeMessage(m2Bytes)
-            .addChangeMessage(m1Bytes)
+            .addChangeMessage(m2Proto)
+            .addChangeMessage(m1Proto)
             .build());
   }
 
@@ -793,6 +832,9 @@
                     "attentionSet",
                     new TypeLiteral<ImmutableSet<AttentionSetUpdate>>() {}.getType())
                 .put(
+                    "allAttentionSetUpdates",
+                    new TypeLiteral<ImmutableList<AttentionSetUpdate>>() {}.getType())
+                .put(
                     "assigneeUpdates",
                     new TypeLiteral<ImmutableList<AssigneeStatusUpdate>>() {}.getType())
                 .put("submitRecords", new TypeLiteral<ImmutableList<SubmitRecord>>() {}.getType())
@@ -801,6 +843,7 @@
                     "publishedComments",
                     new TypeLiteral<ImmutableListMultimap<ObjectId, HumanComment>>() {}.getType())
                 .put("updateCount", int.class)
+                .put("mergedOn", Timestamp.class)
                 .build());
   }
 
@@ -941,6 +984,19 @@
   }
 
   @Test
+  public void serializeMergedOn() throws Exception {
+    assertRoundTrip(
+        newBuilder().mergedOn(new Timestamp(234567L)).build(),
+        ChangeNotesStateProto.newBuilder()
+            .setMetaId(SHA_BYTES)
+            .setChangeId(ID.get())
+            .setMergedOnMillis(234567L)
+            .setHasMergedOn(true)
+            .setColumns(colsProto.toBuilder())
+            .build());
+  }
+
+  @Test
   public void changeMessageFields() throws Exception {
     assertThatSerializedClass(ChangeMessage.Key.class)
         .hasAutoValueMethods(ImmutableMap.of("changeId", Change.Id.class, "uuid", String.class));
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 938fffc..de49cdf 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -45,6 +45,7 @@
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.CommentRange;
 import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.SubmissionId;
@@ -153,12 +154,12 @@
     String tag2 = "ip";
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Verified", (short) -1);
+    update.putApproval(LabelId.VERIFIED, (short) -1);
     update.setTag(tag1);
     update.commit();
 
     update = newUpdate(c, changeOwner);
-    update.putApproval("Verified", (short) 1);
+    update.putApproval(LabelId.VERIFIED, (short) 1);
     update.setTag(tag2);
     update.commit();
 
@@ -177,7 +178,7 @@
     Change c = newChange();
 
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Verified", (short) -1);
+    update.putApproval(LabelId.VERIFIED, (short) -1);
     update.setChangeMessage("integration verification");
     update.setTag(integrationTag);
     update.commit();
@@ -231,8 +232,8 @@
   public void approvalsOnePatchSet() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Verified", (short) 1);
-    update.putApproval("Code-Review", (short) -1);
+    update.putApproval(LabelId.VERIFIED, (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -242,13 +243,13 @@
 
     assertThat(psas.get(0).patchSetId()).isEqualTo(c.currentPatchSetId());
     assertThat(psas.get(0).accountId().get()).isEqualTo(1);
-    assertThat(psas.get(0).label()).isEqualTo("Code-Review");
+    assertThat(psas.get(0).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psas.get(0).value()).isEqualTo((short) -1);
     assertThat(psas.get(0).granted()).isEqualTo(truncate(after(c, 2000)));
 
     assertThat(psas.get(1).patchSetId()).isEqualTo(c.currentPatchSetId());
     assertThat(psas.get(1).accountId().get()).isEqualTo(1);
-    assertThat(psas.get(1).label()).isEqualTo("Verified");
+    assertThat(psas.get(1).label()).isEqualTo(LabelId.VERIFIED);
     assertThat(psas.get(1).value()).isEqualTo((short) 1);
     assertThat(psas.get(1).granted()).isEqualTo(psas.get(0).granted());
   }
@@ -257,13 +258,13 @@
   public void approvalsMultiplePatchSets() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) -1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
     update.commit();
     PatchSet.Id ps1 = c.currentPatchSetId();
 
     incrementPatchSet(c);
     update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
     update.commit();
     PatchSet.Id ps2 = c.currentPatchSetId();
 
@@ -274,14 +275,14 @@
     PatchSetApproval psa1 = Iterables.getOnlyElement(psas.get(ps1));
     assertThat(psa1.patchSetId()).isEqualTo(ps1);
     assertThat(psa1.accountId().get()).isEqualTo(1);
-    assertThat(psa1.label()).isEqualTo("Code-Review");
+    assertThat(psa1.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa1.value()).isEqualTo((short) -1);
     assertThat(psa1.granted()).isEqualTo(truncate(after(c, 2000)));
 
     PatchSetApproval psa2 = Iterables.getOnlyElement(psas.get(ps2));
     assertThat(psa2.patchSetId()).isEqualTo(ps2);
     assertThat(psa2.accountId().get()).isEqualTo(1);
-    assertThat(psa2.label()).isEqualTo("Code-Review");
+    assertThat(psa2.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa2.value()).isEqualTo((short) +1);
     assertThat(psa2.granted()).isEqualTo(truncate(after(c, 4000)));
   }
@@ -290,22 +291,22 @@
   public void approvalsMultipleApprovals() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) -1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
     PatchSetApproval psa =
         Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
-    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa.value()).isEqualTo((short) -1);
 
     update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
     update.commit();
 
     notes = newNotes(c);
     psa = Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
-    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa.value()).isEqualTo((short) 1);
   }
 
@@ -313,11 +314,11 @@
   public void approvalsMultipleUsers() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) -1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
     update.commit();
 
     update = newUpdate(c, otherUser);
-    update.putApproval("Code-Review", (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -327,13 +328,13 @@
 
     assertThat(psas.get(0).patchSetId()).isEqualTo(c.currentPatchSetId());
     assertThat(psas.get(0).accountId().get()).isEqualTo(1);
-    assertThat(psas.get(0).label()).isEqualTo("Code-Review");
+    assertThat(psas.get(0).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psas.get(0).value()).isEqualTo((short) -1);
     assertThat(psas.get(0).granted()).isEqualTo(truncate(after(c, 2000)));
 
     assertThat(psas.get(1).patchSetId()).isEqualTo(c.currentPatchSetId());
     assertThat(psas.get(1).accountId().get()).isEqualTo(2);
-    assertThat(psas.get(1).label()).isEqualTo("Code-Review");
+    assertThat(psas.get(1).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psas.get(1).value()).isEqualTo((short) 1);
     assertThat(psas.get(1).granted()).isEqualTo(truncate(after(c, 3000)));
   }
@@ -413,8 +414,8 @@
   public void putOtherUsersApprovals() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 1);
-    update.putApprovalFor(otherUser.getAccountId(), "Code-Review", (short) -1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
+    update.putApprovalFor(otherUser.getAccountId(), LabelId.CODE_REVIEW, (short) -1);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -425,11 +426,11 @@
     assertThat(approvals).hasSize(2);
 
     assertThat(approvals.get(0).accountId()).isEqualTo(changeOwner.getAccountId());
-    assertThat(approvals.get(0).label()).isEqualTo("Code-Review");
+    assertThat(approvals.get(0).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(approvals.get(0).value()).isEqualTo((short) 1);
 
     assertThat(approvals.get(1).accountId()).isEqualTo(otherUser.getAccountId());
-    assertThat(approvals.get(1).label()).isEqualTo("Code-Review");
+    assertThat(approvals.get(1).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(approvals.get(1).value()).isEqualTo((short) -1);
   }
 
@@ -438,8 +439,8 @@
     Change c = newChange();
     SubmissionId submissionId = new SubmissionId(c);
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 1);
-    update.putApproval("Verified", (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
+    update.putApproval(LabelId.VERIFIED, (short) 1);
     update.commit();
 
     update = newUpdate(c, changeOwner);
@@ -449,21 +450,21 @@
             submitRecord(
                 "NOT_READY",
                 null,
-                submitLabel("Verified", "OK", changeOwner.getAccountId()),
-                submitLabel("Code-Review", "NEED", null))));
+                submitLabel(LabelId.VERIFIED, "OK", changeOwner.getAccountId()),
+                submitLabel(LabelId.CODE_REVIEW, "NEED", null))));
     update.commit();
 
     update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 2);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
     List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().values());
     assertThat(approvals).hasSize(2);
-    assertThat(approvals.get(0).label()).isEqualTo("Verified");
+    assertThat(approvals.get(0).label()).isEqualTo(LabelId.VERIFIED);
     assertThat(approvals.get(0).value()).isEqualTo((short) 1);
     assertThat(approvals.get(0).postSubmit()).isFalse();
-    assertThat(approvals.get(1).label()).isEqualTo("Code-Review");
+    assertThat(approvals.get(1).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(approvals.get(1).value()).isEqualTo((short) 2);
     assertThat(approvals.get(1).postSubmit()).isTrue();
   }
@@ -473,8 +474,8 @@
     Change c = newChange();
     SubmissionId submissionId = new SubmissionId(c);
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 1);
-    update.putApproval("Verified", (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
+    update.putApproval(LabelId.VERIFIED, (short) 1);
     update.commit();
 
     Account.Id ownerId = changeOwner.getAccountId();
@@ -486,10 +487,10 @@
             submitRecord(
                 "NOT_READY",
                 null,
-                submitLabel("Verified", "OK", ownerId),
-                submitLabel("Code-Review", "NEED", null))));
+                submitLabel(LabelId.VERIFIED, "OK", ownerId),
+                submitLabel(LabelId.CODE_REVIEW, "NEED", null))));
     update.putApproval("Other-Label", (short) 1);
-    update.putApprovalFor(ownerId, "Code-Review", (short) 2);
+    update.putApprovalFor(ownerId, LabelId.CODE_REVIEW, (short) 2);
     update.commit();
 
     update = newUpdate(c, otherUser);
@@ -501,11 +502,11 @@
     List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().values());
     assertThat(approvals).hasSize(3);
     assertThat(approvals.get(0).accountId()).isEqualTo(ownerId);
-    assertThat(approvals.get(0).label()).isEqualTo("Verified");
+    assertThat(approvals.get(0).label()).isEqualTo(LabelId.VERIFIED);
     assertThat(approvals.get(0).value()).isEqualTo(1);
     assertThat(approvals.get(0).postSubmit()).isFalse();
     assertThat(approvals.get(1).accountId()).isEqualTo(ownerId);
-    assertThat(approvals.get(1).label()).isEqualTo("Code-Review");
+    assertThat(approvals.get(1).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(approvals.get(1).value()).isEqualTo(2);
     assertThat(approvals.get(1).postSubmit()).isFalse(); // During submit.
     assertThat(approvals.get(2).accountId()).isEqualTo(otherId);
@@ -582,11 +583,11 @@
     update.commit();
 
     update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
     update.commit();
 
     update = newUpdate(c, otherUser);
-    update.putApproval("Code-Review", (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -618,12 +619,12 @@
             submitRecord(
                 "NOT_READY",
                 null,
-                submitLabel("Verified", "OK", changeOwner.getAccountId()),
-                submitLabel("Code-Review", "NEED", null)),
+                submitLabel(LabelId.VERIFIED, "OK", changeOwner.getAccountId()),
+                submitLabel(LabelId.CODE_REVIEW, "NEED", null)),
             submitRecord(
                 "NOT_READY",
                 null,
-                submitLabel("Verified", "OK", changeOwner.getAccountId()),
+                submitLabel(LabelId.VERIFIED, "OK", changeOwner.getAccountId()),
                 submitLabel("Alternative-Code-Review", "NEED", null))));
     update.commit();
 
@@ -635,14 +636,14 @@
             submitRecord(
                 "NOT_READY",
                 null,
-                submitLabel("Verified", "OK", changeOwner.getAccountId()),
-                submitLabel("Code-Review", "NEED", null)));
+                submitLabel(LabelId.VERIFIED, "OK", changeOwner.getAccountId()),
+                submitLabel(LabelId.CODE_REVIEW, "NEED", null)));
     assertThat(recs.get(1))
         .isEqualTo(
             submitRecord(
                 "NOT_READY",
                 null,
-                submitLabel("Verified", "OK", changeOwner.getAccountId()),
+                submitLabel(LabelId.VERIFIED, "OK", changeOwner.getAccountId()),
                 submitLabel("Alternative-Code-Review", "NEED", null)));
     assertThat(notes.getChange().getSubmissionId()).isEqualTo(submissionId.toString());
   }
@@ -656,7 +657,8 @@
     update.merge(
         submissionId,
         ImmutableList.of(
-            submitRecord("OK", null, submitLabel("Code-Review", "OK", otherUser.getAccountId()))));
+            submitRecord(
+                "OK", null, submitLabel(LabelId.CODE_REVIEW, "OK", otherUser.getAccountId()))));
     update.commit();
 
     incrementPatchSet(c);
@@ -666,17 +668,88 @@
         submissionId,
         ImmutableList.of(
             submitRecord(
-                "OK", null, submitLabel("Code-Review", "OK", changeOwner.getAccountId()))));
+                "OK", null, submitLabel(LabelId.CODE_REVIEW, "OK", changeOwner.getAccountId()))));
     update.commit();
 
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getSubmitRecords())
         .containsExactly(
-            submitRecord("OK", null, submitLabel("Code-Review", "OK", changeOwner.getAccountId())));
+            submitRecord(
+                "OK", null, submitLabel(LabelId.CODE_REVIEW, "OK", changeOwner.getAccountId())));
     assertThat(notes.getChange().getSubmissionId()).isEqualTo(submissionId.toString());
   }
 
   @Test
+  public void mergedOnEmptyIfNotSubmitted() throws Exception {
+    Change c = newChange();
+
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    // Make sure unrelevent update does not set mergedOn.
+    update.setTopic("topic");
+    update.commit();
+    assertThat(newNotes(c).getMergedOn()).isEmpty();
+  }
+
+  @Test
+  public void mergedOnSetWhenSubmitted() throws Exception {
+    Change c = newChange();
+
+    SubmissionId submissionId = new SubmissionId(c);
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setSubjectForCommit("Update patch set 1");
+    update.merge(
+        submissionId,
+        ImmutableList.of(
+            submitRecord(
+                "OK", null, submitLabel(LabelId.CODE_REVIEW, "OK", otherUser.getAccountId()))));
+    update.commit();
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getMergedOn()).isPresent();
+    Timestamp mergedOn = notes.getMergedOn().get();
+    assertThat(mergedOn).isEqualTo(notes.getChange().getLastUpdatedOn());
+
+    // Next update does not change mergedOn date.
+    update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.getMergedOn().get()).isEqualTo(mergedOn);
+    assertThat(notes.getMergedOn().get()).isLessThan(notes.getChange().getLastUpdatedOn());
+  }
+
+  @Test
+  public void latestMergedOn() throws Exception {
+    Change c = newChange();
+    SubmissionId submissionId = new SubmissionId(c);
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setSubjectForCommit("Update patch set 1");
+    update.merge(
+        submissionId,
+        ImmutableList.of(
+            submitRecord(
+                "OK", null, submitLabel(LabelId.CODE_REVIEW, "OK", otherUser.getAccountId()))));
+    update.commit();
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getMergedOn()).isPresent();
+    Timestamp mergedOn = notes.getMergedOn().get();
+    assertThat(mergedOn).isEqualTo(notes.getChange().getLastUpdatedOn());
+
+    incrementPatchSet(c);
+    update = newUpdate(c, changeOwner);
+    update.setSubjectForCommit("Update patch set 2");
+    update.merge(
+        submissionId,
+        ImmutableList.of(
+            submitRecord(
+                "OK", null, submitLabel(LabelId.CODE_REVIEW, "OK", changeOwner.getAccountId()))));
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getMergedOn().get()).isGreaterThan(mergedOn);
+    assertThat(notes.getMergedOn().get()).isEqualTo(notes.getChange().getLastUpdatedOn());
+  }
+
+  @Test
   public void emptyChangeUpdate() throws Exception {
     Change c = newChange();
     Ref initial = repo.exactRef(changeMetaRef(c.getId()));
@@ -699,6 +772,13 @@
   }
 
   @Test
+  public void defaultAttentionSetUpdatesIsEmpty() throws Exception {
+    Change c = newChange();
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getAttentionSetUpdates()).isEmpty();
+  }
+
+  @Test
   public void addAttentionStatus() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -712,6 +792,19 @@
   }
 
   @Test
+  public void addAllAttentionUpdates() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    AttentionSetUpdate attentionSetUpdate =
+        AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
+    update.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getAttentionSetUpdates()).containsExactly(addTimestamp(attentionSetUpdate, c));
+  }
+
+  @Test
   public void filterLatestAttentionStatus() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -730,6 +823,28 @@
   }
 
   @Test
+  public void DoesNotFilterLatestAttentionSetUpdates() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    AttentionSetUpdate firstAttentionSetUpdate =
+        AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
+    update.addToPlannedAttentionSetUpdates(ImmutableSet.of(firstAttentionSetUpdate));
+    update.commit();
+    update = newUpdate(c, changeOwner);
+    firstAttentionSetUpdate = addTimestamp(firstAttentionSetUpdate, c);
+
+    AttentionSetUpdate secondAttentionSetUpdate =
+        AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.REMOVE, "test");
+    update.addToPlannedAttentionSetUpdates(ImmutableSet.of(secondAttentionSetUpdate));
+    update.commit();
+    secondAttentionSetUpdate = addTimestamp(secondAttentionSetUpdate, c);
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getAttentionSetUpdates())
+        .containsExactly(secondAttentionSetUpdate, firstAttentionSetUpdate);
+  }
+
+  @Test
   public void addAttentionStatus_rejectTimestamp() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -767,6 +882,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 =
@@ -1050,7 +1167,7 @@
     assertThat(ts5).isGreaterThan(ts4);
 
     update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
     update.commit();
     Timestamp ts6 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts6).isGreaterThan(ts5);
@@ -1081,7 +1198,7 @@
             submitRecord(
                 "NOT_READY",
                 null,
-                submitLabel("Verified", "OK", changeOwner.getAccountId()),
+                submitLabel(LabelId.VERIFIED, "OK", changeOwner.getAccountId()),
                 submitLabel("Alternative-Code-Review", "NEED", null))));
     update.commit();
     Timestamp ts10 = newNotes(c).getChange().getLastUpdatedOn();
@@ -1185,7 +1302,7 @@
     RevCommit commit = tr.commit().message("PS2").create();
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setCommit(rw, commit);
-    update.putApproval("Code-Review", (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
     update.setChangeMessage("This is a message");
     update.putComment(
         HumanComment.Status.PUBLISHED,
@@ -1323,10 +1440,10 @@
   public void multipleUpdatesInManager() throws Exception {
     Change c = newChange();
     ChangeUpdate update1 = newUpdate(c, changeOwner);
-    update1.putApproval("Verified", (short) 1);
+    update1.putApproval(LabelId.VERIFIED, (short) 1);
 
     ChangeUpdate update2 = newUpdate(c, otherUser);
-    update2.putApproval("Code-Review", (short) 2);
+    update2.putApproval(LabelId.CODE_REVIEW, (short) 2);
 
     try (NoteDbUpdateManager updateManager = updateManagerFactory.create(project)) {
       updateManager.add(update1);
@@ -1339,11 +1456,11 @@
     assertThat(psas).hasSize(2);
 
     assertThat(psas.get(0).accountId()).isEqualTo(changeOwner.getAccount().id());
-    assertThat(psas.get(0).label()).isEqualTo("Verified");
+    assertThat(psas.get(0).label()).isEqualTo(LabelId.VERIFIED);
     assertThat(psas.get(0).value()).isEqualTo((short) 1);
 
     assertThat(psas.get(1).accountId()).isEqualTo(otherUser.getAccount().id());
-    assertThat(psas.get(1).label()).isEqualTo("Code-Review");
+    assertThat(psas.get(1).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psas.get(1).value()).isEqualTo((short) 2);
   }
 
@@ -1377,7 +1494,7 @@
       updateManager.add(update1);
 
       ChangeUpdate update2 = newUpdate(c, otherUser);
-      update2.putApproval("Code-Review", (short) 2);
+      update2.putApproval(LabelId.CODE_REVIEW, (short) 2);
       updateManager.add(update2);
 
       updateManager.execute();
@@ -1416,11 +1533,11 @@
   public void multipleUpdatesAcrossRefs() throws Exception {
     Change c1 = newChange();
     ChangeUpdate update1 = newUpdate(c1, changeOwner);
-    update1.putApproval("Verified", (short) 1);
+    update1.putApproval(LabelId.VERIFIED, (short) 1);
 
     Change c2 = newChange();
     ChangeUpdate update2 = newUpdate(c2, otherUser);
-    update2.putApproval("Code-Review", (short) 2);
+    update2.putApproval(LabelId.CODE_REVIEW, (short) 2);
 
     Ref initial1 = repo.exactRef(update1.getRefName());
     assertThat(initial1).isNotNull();
@@ -1442,11 +1559,11 @@
 
     PatchSetApproval approval1 =
         newNotes(c1).getApprovals().get(c1.currentPatchSetId()).iterator().next();
-    assertThat(approval1.label()).isEqualTo("Verified");
+    assertThat(approval1.label()).isEqualTo(LabelId.VERIFIED);
 
     PatchSetApproval approval2 =
         newNotes(c2).getApprovals().get(c2.currentPatchSetId()).iterator().next();
-    assertThat(approval2.label()).isEqualTo("Code-Review");
+    assertThat(approval2.label()).isEqualTo(LabelId.CODE_REVIEW);
   }
 
   @Test
@@ -2660,7 +2777,8 @@
 
     ChangeUpdate failingUpdate = newUpdate(c, internalUser);
     assertThrows(
-        IllegalStateException.class, () -> failingUpdate.putApproval("Code-Review", (short) 1));
+        IllegalStateException.class,
+        () -> failingUpdate.putApproval(LabelId.CODE_REVIEW, (short) 1));
   }
 
   @Test
@@ -2822,7 +2940,7 @@
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setPatchSetId(PatchSet.id(c.getId(), c.currentPatchSetId().get() + 1));
     update.setChangeMessage("Should be ignored");
-    update.putApproval("Code-Review", (short) 2);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 2);
     CommentRange range = new CommentRange(1, 1, 2, 1);
     HumanComment comment =
         newComment(
@@ -3146,17 +3264,45 @@
   }
 
   @Test
+  public void resetCherryPickOf() throws Exception {
+    Change destinationChange = newChange();
+    Change cherryPickChange = newChange();
+    assertThat(newNotes(destinationChange).getChange().getCherryPickOf()).isNull();
+
+    ChangeUpdate update = newUpdate(destinationChange, changeOwner);
+    update.setCherryPickOf(
+        cherryPickChange.currentPatchSetId().getCommaSeparatedChangeAndPatchSetId());
+    update.commit();
+    assertThat(newNotes(destinationChange).getChange().getCherryPickOf())
+        .isEqualTo(cherryPickChange.currentPatchSetId());
+
+    update = newUpdate(destinationChange, changeOwner);
+    update.resetCherryPickOf();
+    update.commit();
+    assertThat(newNotes(destinationChange).getChange().getCherryPickOf()).isNull();
+
+    // Can set again after reset.
+    cherryPickChange = newChange();
+    update = newUpdate(destinationChange, changeOwner);
+    update.setCherryPickOf(
+        cherryPickChange.currentPatchSetId().getCommaSeparatedChangeAndPatchSetId());
+    update.commit();
+    assertThat(newNotes(destinationChange).getChange().getCherryPickOf())
+        .isEqualTo(cherryPickChange.currentPatchSetId());
+  }
+
+  @Test
   public void updateCount() throws Exception {
     Change c = newChange();
     assertThat(newNotes(c).getUpdateCount()).isEqualTo(1);
 
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) -1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
     update.commit();
     assertThat(newNotes(c).getUpdateCount()).isEqualTo(2);
 
     update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
     update.commit();
     assertThat(newNotes(c).getUpdateCount()).isEqualTo(3);
   }
diff --git a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index 6cfd9f2d..68a1d9d 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -39,8 +40,8 @@
   public void approvalsCommitFormatSimple() throws Exception {
     Change c = TestChanges.newChange(project, changeOwner.getAccountId(), 1);
     ChangeUpdate update = newUpdateForNewChange(c, changeOwner);
-    update.putApproval("Verified", (short) 1);
-    update.putApproval("Code-Review", (short) -1);
+    update.putApproval(LabelId.VERIFIED, (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
     update.putReviewer(changeOwner.getAccount().id(), REVIEWER);
     update.putReviewer(otherUser.getAccount().id(), CC);
     update.commit();
@@ -61,8 +62,7 @@
             + "Reviewer: Gerrit User 1 <1@gerrit>\n"
             + "CC: Gerrit User 2 <2@gerrit>\n"
             + "Label: Code-Review=-1\n"
-            + "Label: Verified=+1\n"
-            + "Attention: {\"person_ident\":\"Gerrit User 1 \\u003c1@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Reviewer was added\"}\n",
+            + "Label: Verified=+1\n",
         commit);
 
     PersonIdent author = commit.getAuthorIdent();
@@ -135,7 +135,7 @@
   public void approvalTombstoneCommitFormat() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.removeApproval("Code-Review");
+    update.removeApproval(LabelId.CODE_REVIEW);
     update.commit();
 
     assertBodyEquals(
@@ -155,12 +155,12 @@
             submitRecord(
                 "NOT_READY",
                 null,
-                submitLabel("Verified", "OK", changeOwner.getAccountId()),
-                submitLabel("Code-Review", "NEED", null)),
+                submitLabel(LabelId.VERIFIED, "OK", changeOwner.getAccountId()),
+                submitLabel(LabelId.CODE_REVIEW, "NEED", null)),
             submitRecord(
                 "NOT_READY",
                 null,
-                submitLabel("Verified", "OK", changeOwner.getAccountId()),
+                submitLabel(LabelId.VERIFIED, "OK", changeOwner.getAccountId()),
                 submitLabel("Alternative-Code-Review", "NEED", null))));
     update.commit();
 
@@ -245,8 +245,7 @@
     update.commit();
 
     assertBodyEquals(
-        "Update patch set 1\n\nPatch-set: 1\nReviewer: Gerrit User 1 <1@gerrit>\n"
-            + "Attention: {\"person_ident\":\"Gerrit User 1 \\u003c1@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Reviewer was added\"}\n",
+        "Update patch set 1\n\nPatch-set: 1\nReviewer: Gerrit User 1 <1@gerrit>\n",
         update.getResult());
   }
 
diff --git a/javatests/com/google/gerrit/server/patch/PatchListTest.java b/javatests/com/google/gerrit/server/patch/PatchListTest.java
index 56adefa..182ce49 100644
--- a/javatests/com/google/gerrit/server/patch/PatchListTest.java
+++ b/javatests/com/google/gerrit/server/patch/PatchListTest.java
@@ -17,12 +17,15 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.server.patch.PatchList.ChangeTypeCmp;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.InputStream;
 import java.io.ObjectInputStream;
 import java.io.ObjectOutputStream;
 import java.util.Arrays;
+import java.util.List;
 import org.junit.Test;
 
 public class PatchListTest {
@@ -67,6 +70,15 @@
   }
 
   @Test
+  public void changeTypeOrderIsComplete() {
+    List<ChangeType> changeTypeOrder = ChangeTypeCmp.order;
+    ChangeType[] allTypes = ChangeType.values();
+
+    Arrays.sort(allTypes, PatchList.CHANGE_TYPE_CMP);
+    assertThat(changeTypeOrder).containsExactlyElementsIn(allTypes).inOrder();
+  }
+
+  @Test
   public void largeObjectTombstoneCanBeSerializedAndDeserialized() throws Exception {
     // Serialize
     byte[] serializedObject;
diff --git a/javatests/com/google/gerrit/server/permissions/RefControlTest.java b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
index 87db21f..c5bef59 100644
--- a/javatests/com/google/gerrit/server/permissions/RefControlTest.java
+++ b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PermissionRange;
 import com.google.gerrit.entities.Project;
@@ -651,12 +652,13 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(blockLabel("Code-Review").ref("refs/heads/master").group(DEVS).range(+1, +2))
+        .add(blockLabel(LabelId.CODE_REVIEW).ref("refs/heads/master").group(DEVS).range(+1, +2))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
 
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCannotVote(2, range);
   }
 
@@ -665,17 +667,18 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-2, +2))
         .update();
     projectOperations
         .project(parentKey)
         .forUpdate()
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .add(blockLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-2, +2))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
 
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCanVote(-1, range);
     assertCanVote(1, range);
     assertCannotVote(-2, range);
@@ -687,18 +690,19 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .add(blockLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-2, +2))
         .update();
     projectOperations
         .project(parentKey)
         .forUpdate()
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .add(blockLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-2, +2))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
 
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCanVote(-1, range);
     assertCanVote(1, range);
     assertCannotVote(-2, range);
@@ -832,13 +836,15 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
-        .add(allowLabel("Code-Review").ref("refs/heads/master").group(DEVS).range(-2, 2))
-        .setExclusiveGroup(labelPermissionKey("Code-Review").ref("refs/heads/master"), true)
+        .add(
+            blockLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/master").group(DEVS).range(-2, 2))
+        .setExclusiveGroup(labelPermissionKey(LabelId.CODE_REVIEW).ref("refs/heads/master"), true)
         .update();
 
     ProjectControl u = user(localKey, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCanVote(-2, range);
   }
 
@@ -1013,12 +1019,17 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, +1))
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .add(
+            blockLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(ANONYMOUS_USERS)
+                .range(-1, +1))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-2, +2))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCanVote(-2, range);
     assertCanVote(2, range);
   }
@@ -1028,12 +1039,17 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, +1))
-        .add(allowLabel("Code-Review").ref("refs/heads/master").group(DEVS).range(-2, +2))
+        .add(
+            blockLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(ANONYMOUS_USERS)
+                .range(-1, +1))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/master").group(DEVS).range(-2, +2))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
   }
@@ -1044,12 +1060,16 @@
         .project(localKey)
         .forUpdate()
         .add(
-            blockLabel("Code-Review").ref("refs/heads/master").group(ANONYMOUS_USERS).range(-1, +1))
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+            blockLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/master")
+                .group(ANONYMOUS_USERS)
+                .range(-1, +1))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-2, +2))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
   }
@@ -1059,11 +1079,12 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-2, +2))
         .update();
 
     ProjectControl u = user(localKey, REGISTERED_USERS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCannotVote(-1, range);
     assertCannotVote(1, range);
   }
@@ -1073,16 +1094,18 @@
     projectOperations
         .project(parentKey)
         .forUpdate()
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
+        .add(
+            blockLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
         .update();
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-2, +2))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
   }
@@ -1092,12 +1115,12 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(CHANGE_OWNER).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(CHANGE_OWNER).range(-2, +2))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
     PermissionRange range =
-        u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review", true);
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW, true);
     assertCanVote(-2, range);
     assertCanVote(2, range);
   }
@@ -1107,11 +1130,12 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(CHANGE_OWNER).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(CHANGE_OWNER).range(-2, +2))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
   }
@@ -1121,11 +1145,12 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(CHANGE_OWNER).range(-2, +2))
+        .add(blockLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(CHANGE_OWNER).range(-2, +2))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
   }
@@ -1135,12 +1160,17 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-1, +1))
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-1, +1))
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, +2))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCanVote(-2, range);
     assertCanVote(2, range);
   }
@@ -1150,12 +1180,17 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +2))
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-1, +1))
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-1, +1))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCanVote(-2, range);
     assertCanVote(2, range);
   }
@@ -1165,17 +1200,26 @@
     projectOperations
         .project(parentKey)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-1, +1))
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-1, +1))
+        .add(
+            blockLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, +2))
         .update();
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +1))
+        .add(
+            blockLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, +1))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCanVote(-1, range);
     assertCannotVote(1, range);
   }
diff --git a/javatests/com/google/gerrit/server/project/GroupListTest.java b/javatests/com/google/gerrit/server/project/GroupListTest.java
index 7d4b7ca..853507d 100644
--- a/javatests/com/google/gerrit/server/project/GroupListTest.java
+++ b/javatests/com/google/gerrit/server/project/GroupListTest.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.git.ValidationError;
 import java.io.IOException;
 import java.util.Collection;
@@ -57,7 +58,7 @@
     GroupReference groupReference = groupList.byUUID(uuid);
 
     assertEquals(uuid, groupReference.getUUID());
-    assertEquals("Service Users", groupReference.getName());
+    assertEquals(ServiceUserClassifier.SERVICE_USERS, groupReference.getName());
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index a39821e..aecb5d3 100644
--- a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
+++ b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
@@ -77,6 +77,9 @@
           + "  copyMaxScore = "
           + !LabelType.DEF_COPY_MAX_SCORE
           + "\n"
+          + "  copyAllScoresIfListOfFilesDidNotChange = "
+          + !LabelType.DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE
+          + "\n"
           + "  copyAllScoresOnMergeFirstParentUpdate = "
           + !LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE
           + "\n"
@@ -270,6 +273,8 @@
     assertThat(type.isCopyAnyScore()).isNotEqualTo(LabelType.DEF_COPY_ANY_SCORE);
     assertThat(type.isCopyMinScore()).isNotEqualTo(LabelType.DEF_COPY_MIN_SCORE);
     assertThat(type.isCopyMaxScore()).isNotEqualTo(LabelType.DEF_COPY_MAX_SCORE);
+    assertThat(type.isCopyAllScoresIfListOfFilesDidNotChange())
+        .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE);
     assertThat(type.isCopyAllScoresOnMergeFirstParentUpdate())
         .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
     assertThat(type.isCopyAllScoresOnTrivialRebase())
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 02f514a..48bd321 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -49,6 +49,7 @@
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
@@ -85,6 +86,7 @@
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.query.IndexPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.lifecycle.LifecycleManager;
@@ -110,6 +112,7 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.index.change.IndexedChangeQuery;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.project.ProjectCache;
@@ -702,6 +705,24 @@
   }
 
   @Test
+  public void byParentOf() throws Exception {
+    TestRepository<Repo> repo1 = createProject("repo1");
+    RevCommit commit1 = repo1.parseBody(repo1.commit().message("message").create());
+    Change change1 = insert(repo1, newChangeForCommit(repo1, commit1));
+    RevCommit commit2 = repo1.parseBody(repo1.commit(commit1));
+    Change change2 = insert(repo1, newChangeForCommit(repo1, commit2));
+    RevCommit commit3 = repo1.parseBody(repo1.commit(commit1, commit2));
+    Change change3 = insert(repo1, newChangeForCommit(repo1, commit3));
+
+    assertQuery("parentof:" + change1.getId().get());
+    assertQuery("parentof:" + change1.getKey().get());
+    assertQuery("parentof:" + change2.getId().get(), change1);
+    assertQuery("parentof:" + change2.getKey().get(), change1);
+    assertQuery("parentof:" + change3.getId().get(), change2, change1);
+    assertQuery("parentof:" + change3.getKey().get(), change2, change1);
+  }
+
+  @Test
   public void byParentProject() throws Exception {
     TestRepository<Repo> repo1 = createProject("repo1");
     TestRepository<Repo> repo2 = createProject("repo2", "repo1");
@@ -1006,7 +1027,7 @@
         Project.nameKey(repo.getRepository().getDescription().getRepositoryName());
 
     LabelType verified =
-        label("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+        label(LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
     try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
       ProjectConfig cfg = projectConfigFactory.create(project);
       cfg.load(md);
@@ -1022,7 +1043,7 @@
         .add(allowLabel(verified.getName()).ref(heads).group(REGISTERED_USERS).range(-1, 1))
         .update();
 
-    ReviewInput reviewVerified = new ReviewInput().label("Verified", 1);
+    ReviewInput reviewVerified = new ReviewInput().label(LabelId.VERIFIED, 1);
     ChangeInserter ins = newChange(repo);
     ChangeInserter ins2 = newChange(repo);
     ChangeInserter ins3 = newChange(repo);
@@ -1573,6 +1594,10 @@
     assertThat(nowMs - lastUpdatedMs(change2)).isEqualTo(thirtyHoursInMs);
     assertThat(TimeUtil.nowMs()).isEqualTo(nowMs);
 
+    // Change1 was last updated on 2009-09-30 21:00:00 -0000
+    // Change2 was last updated on 2009-10-02 03:00:00 -0000
+    // The endpoint is 2009-10-03 09:00:00 -0000
+
     assertQuery("-age:1d");
     assertQuery("-age:" + (30 * 60 - 1) + "m");
     assertQuery("-age:2d", change2);
@@ -1580,6 +1605,15 @@
     assertQuery("age:3d");
     assertQuery("age:2d", change1);
     assertQuery("age:1d", change2, change1);
+
+    // Same test as above, but using filter code path.
+    assertQuery(makeIndexedPredicateFilterQuery("-age:1d"));
+    assertQuery(makeIndexedPredicateFilterQuery("-age:" + (30 * 60 - 1) + "m"));
+    assertQuery(makeIndexedPredicateFilterQuery("-age:2d"), change2);
+    assertQuery(makeIndexedPredicateFilterQuery("-age:3d"), change2, change1);
+    assertQuery(makeIndexedPredicateFilterQuery("age:3d"));
+    assertQuery(makeIndexedPredicateFilterQuery("age:2d"), change1);
+    assertQuery(makeIndexedPredicateFilterQuery("age:1d"), change2, change1);
   }
 
   @Test
@@ -1592,6 +1626,9 @@
     Change change2 = insert(repo, newChange(repo), null, new Timestamp(startMs + thirtyHoursInMs));
     TestTimeUtil.setClockStep(0, MILLISECONDS);
 
+    // Change1 was last updated on 2009-09-30 21:00:00 -0000
+    // Change2 was last updated on 2009-10-02 03:00:00 -0000
+
     for (String predicate : Lists.newArrayList("before:", "until:")) {
       assertQuery(predicate + "2009-09-29");
       assertQuery(predicate + "2009-09-30");
@@ -1604,6 +1641,22 @@
       assertQuery(predicate + "2009-10-01", change1);
       assertQuery(predicate + "2009-10-03", change2, change1);
     }
+
+    // Same test as above, but using filter code path.
+    for (String predicate : Lists.newArrayList("before:", "until:")) {
+      assertQuery(makeIndexedPredicateFilterQuery(predicate + "2009-09-29"));
+      assertQuery(makeIndexedPredicateFilterQuery(predicate + "2009-09-30"));
+      assertQuery(makeIndexedPredicateFilterQuery(predicate + "\"2009-09-30 16:59:00 -0400\""));
+      assertQuery(makeIndexedPredicateFilterQuery(predicate + "\"2009-09-30 20:59:00 -0000\""));
+      assertQuery(makeIndexedPredicateFilterQuery(predicate + "\"2009-09-30 20:59:00\""));
+      assertQuery(
+          makeIndexedPredicateFilterQuery(predicate + "\"2009-09-30 17:02:00 -0400\""), change1);
+      assertQuery(
+          makeIndexedPredicateFilterQuery(predicate + "\"2009-10-01 21:02:00 -0000\""), change1);
+      assertQuery(makeIndexedPredicateFilterQuery(predicate + "\"2009-10-01 21:02:00\""), change1);
+      assertQuery(makeIndexedPredicateFilterQuery(predicate + "2009-10-01"), change1);
+      assertQuery(makeIndexedPredicateFilterQuery(predicate + "2009-10-03"), change2, change1);
+    }
   }
 
   @Test
@@ -1616,6 +1669,8 @@
     Change change2 = insert(repo, newChange(repo), null, new Timestamp(startMs + thirtyHoursInMs));
     TestTimeUtil.setClockStep(0, MILLISECONDS);
 
+    // Change1 was last updated on 2009-09-30 21:00:00 -0000
+    // Change2 was last updated on 2009-10-02 03:00:00 -0000
     for (String predicate : Lists.newArrayList("after:", "since:")) {
       assertQuery(predicate + "2009-10-03");
       assertQuery(predicate + "\"2009-10-01 20:59:59 -0400\"", change2);
@@ -1623,6 +1678,197 @@
       assertQuery(predicate + "2009-10-01", change2);
       assertQuery(predicate + "2009-09-30", change2, change1);
     }
+
+    // Same test as above, but using filter code path.
+    for (String predicate : Lists.newArrayList("after:", "since:")) {
+      assertQuery(makeIndexedPredicateFilterQuery(predicate + "2009-10-03"));
+      assertQuery(
+          makeIndexedPredicateFilterQuery(predicate + "\"2009-10-01 20:59:59 -0400\""), change2);
+      assertQuery(
+          makeIndexedPredicateFilterQuery(predicate + "\"2009-10-01 20:59:59 -0000\""), change2);
+      assertQuery(makeIndexedPredicateFilterQuery(predicate + "2009-10-01"), change2);
+      assertQuery(makeIndexedPredicateFilterQuery(predicate + "2009-09-30"), change2, change1);
+    }
+  }
+
+  @Test
+  public void mergedOperatorSupportedByIndexVersion() throws Exception {
+    if (getSchemaVersion() < 61) {
+      assertMissingField(ChangeField.MERGED_ON);
+      assertFailingQuery(
+          "mergedbefore:2009-10-01",
+          "'mergedbefore' operator is not supported by change index version");
+      assertFailingQuery(
+          "mergedafter:2009-10-01",
+          "'mergedafter' operator is not supported by change index version");
+    } else {
+      assertThat(getSchema().hasField(ChangeField.MERGED_ON)).isTrue();
+    }
+  }
+
+  @Test
+  public void byMergedBefore() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.MERGED_ON)).isTrue();
+    long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
+
+    // Stop the clock, will set time to specific test values.
+    resetTimeWithClockStep(0, MILLISECONDS);
+    TestRepository<Repo> repo = createProject("repo");
+    long startMs = TestTimeUtil.START.toEpochMilli();
+    TestTimeUtil.setClock(new Timestamp(startMs));
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+    Change change3 = insert(repo, newChange(repo));
+
+    TestTimeUtil.setClock(new Timestamp(startMs + thirtyHoursInMs));
+    submit(change3);
+    TestTimeUtil.setClock(new Timestamp(startMs + 2 * thirtyHoursInMs));
+    submit(change2);
+    TestTimeUtil.setClock(new Timestamp(startMs + 3 * thirtyHoursInMs));
+    // Put another approval on the change, just to update it.
+    approve(change1);
+    approve(change3);
+
+    assertThat(TimeUtil.nowMs()).isEqualTo(startMs + 3 * thirtyHoursInMs);
+    assertThat(lastUpdatedMsApi(change3)).isEqualTo(startMs + 3 * thirtyHoursInMs);
+    assertThat(lastUpdatedMsApi(change2)).isEqualTo(startMs + 2 * thirtyHoursInMs);
+    assertThat(lastUpdatedMsApi(change1)).isEqualTo(startMs + 3 * thirtyHoursInMs);
+
+    // Verify that:
+    // 1. Change1 was not submitted and should be never returned.
+    // 2. Change2 was merged on 2009-10-02 03:00:00 -0000
+    // 3. Change3 was merged on 2009-10-03 09:00:00.0 -0000
+    assertQuery("mergedbefore:2009-10-01");
+    // Changes excluded on the date submitted.
+    assertQuery("mergedbefore:2009-10-02");
+    assertQuery("mergedbefore:\"2009-10-01 22:59:00 -0400\"");
+    assertQuery("mergedbefore:\"2009-10-01 02:59:00\"");
+    assertQuery("mergedbefore:\"2009-10-01 23:02:00 -0400\"", change3);
+    assertQuery("mergedbefore:\"2009-10-02 03:02:00 -0000\"", change3);
+    assertQuery("mergedbefore:\"2009-10-02 03:02:00\"", change3);
+    assertQuery("mergedbefore:2009-10-03", change3);
+    // Changes are sorted by lastUpdatedOn first, then by mergedOn.
+    // Even though Change2 was merged after Change3, Change3 is returned first.
+    assertQuery("mergedbefore:2009-10-04", change3, change2);
+
+    // Same test as above, but using filter code path.
+    assertQuery(makeIndexedPredicateFilterQuery("mergedbefore:2009-10-01"));
+    assertQuery(makeIndexedPredicateFilterQuery("mergedbefore:2009-10-02"));
+    assertQuery(makeIndexedPredicateFilterQuery("mergedbefore:\"2009-10-01 22:59:00 -0400\""));
+    assertQuery(makeIndexedPredicateFilterQuery("mergedbefore:\"2009-10-01 02:59:00\""));
+    assertQuery(
+        makeIndexedPredicateFilterQuery("mergedbefore:\"2009-10-01 23:02:00 -0400\""), change3);
+    assertQuery(
+        makeIndexedPredicateFilterQuery("mergedbefore:\"2009-10-02 03:02:00 -0000\""), change3);
+    assertQuery(makeIndexedPredicateFilterQuery("mergedbefore:\"2009-10-02 03:02:00\""), change3);
+    assertQuery(makeIndexedPredicateFilterQuery("mergedbefore:2009-10-03"), change3);
+    assertQuery(makeIndexedPredicateFilterQuery("mergedbefore:2009-10-04"), change3, change2);
+  }
+
+  @Test
+  public void byMergedAfter() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.MERGED_ON)).isTrue();
+    long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
+
+    // Stop the clock, will set time to specific test values.
+    resetTimeWithClockStep(0, MILLISECONDS);
+    TestRepository<Repo> repo = createProject("repo");
+    long startMs = TestTimeUtil.START.toEpochMilli();
+    TestTimeUtil.setClock(new Timestamp(startMs));
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+    Change change3 = insert(repo, newChange(repo));
+    assertThat(TimeUtil.nowMs()).isEqualTo(startMs);
+
+    TestTimeUtil.setClock(new Timestamp(startMs + thirtyHoursInMs));
+    submit(change3);
+
+    TestTimeUtil.setClock(new Timestamp(startMs + 2 * thirtyHoursInMs));
+    submit(change2);
+
+    TestTimeUtil.setClock(new Timestamp(startMs + 3 * thirtyHoursInMs));
+    // Put another approval on the change, just to update it.
+    approve(change1);
+    approve(change3);
+
+    assertThat(TimeUtil.nowMs()).isEqualTo(startMs + 3 * thirtyHoursInMs);
+
+    assertThat(lastUpdatedMsApi(change3)).isEqualTo(startMs + 3 * thirtyHoursInMs);
+    assertThat(lastUpdatedMsApi(change2)).isEqualTo(startMs + 2 * thirtyHoursInMs);
+    assertThat(lastUpdatedMsApi(change1)).isEqualTo(startMs + 3 * thirtyHoursInMs);
+
+    // Verify that:
+    // 1. Change1 was not submitted and should be never returned.
+    // 2. Change2 was merged on 2009-10-02 03:00:00 -0000
+    // 3. Change3 was merged on 2009-10-03 09:00:00.0 -0000
+    assertQuery("mergedafter:2009-10-01", change3, change2);
+    // Changes are sorted by lastUpdatedOn first, then by mergedOn.
+    // Even though Change2 was merged after Change3, Change3 is returned first.
+    assertQuery("mergedafter:\"2009-10-01 22:59:00 -0400\"", change3, change2);
+    assertQuery("mergedafter:\"2009-10-02 02:59:00 -0000\"", change3, change2);
+    assertQuery("mergedafter:\"2009-10-01 23:02:00 -0400\"", change2);
+    assertQuery("mergedafter:\"2009-10-02 03:02:00 -0000\"", change2);
+    // Changes included on the date submitted.
+    assertQuery("mergedafter:2009-10-02", change3, change2);
+    assertQuery("mergedafter:2009-10-03", change2);
+
+    // Same test as above, but using filter code path.
+
+    assertQuery(makeIndexedPredicateFilterQuery("mergedafter:2009-10-01"), change3, change2);
+    // Changes are sorted by lastUpdatedOn first, then by mergedOn.
+    // Even though Change2 was merged after Change3, Change3 is returned first.
+    assertQuery(
+        makeIndexedPredicateFilterQuery("mergedafter:\"2009-10-01 22:59:00 -0400\""),
+        change3,
+        change2);
+    assertQuery(
+        makeIndexedPredicateFilterQuery("mergedafter:\"2009-10-02 02:59:00 -0000\""),
+        change3,
+        change2);
+    assertQuery(
+        makeIndexedPredicateFilterQuery("mergedafter:\"2009-10-01 23:02:00 -0400\""), change2);
+    assertQuery(
+        makeIndexedPredicateFilterQuery("mergedafter:\"2009-10-02 03:02:00 -0000\""), change2);
+    // Changes included on the date submitted.
+    assertQuery(makeIndexedPredicateFilterQuery("mergedafter:2009-10-02"), change3, change2);
+    assertQuery(makeIndexedPredicateFilterQuery("mergedafter:2009-10-03"), change2);
+  }
+
+  @Test
+  public void updatedThenMergedOrder() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.MERGED_ON)).isTrue();
+    long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
+
+    // Stop the clock, will set time to specific test values.
+    resetTimeWithClockStep(0, MILLISECONDS);
+    TestRepository<Repo> repo = createProject("repo");
+    long startMs = TestTimeUtil.START.toEpochMilli();
+    TestTimeUtil.setClock(new Timestamp(startMs));
+
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+    Change change3 = insert(repo, newChange(repo));
+
+    TestTimeUtil.setClock(new Timestamp(startMs + thirtyHoursInMs));
+    submit(change2);
+    submit(change3);
+    TestTimeUtil.setClock(new Timestamp(startMs + 2 * thirtyHoursInMs));
+    // Approve post submit just to update lastUpdatedOn
+    approve(change3);
+    approve(change2);
+    submit(change1);
+
+    // All Changes were last updated at the same time.
+    assertThat(lastUpdatedMsApi(change3)).isEqualTo(startMs + 2 * thirtyHoursInMs);
+    assertThat(lastUpdatedMsApi(change2)).isEqualTo(startMs + 2 * thirtyHoursInMs);
+    assertThat(lastUpdatedMsApi(change1)).isEqualTo(startMs + 2 * thirtyHoursInMs);
+
+    // Changes are sorted by lastUpdatedOn first, then by mergedOn, then by Id in reverse order.
+    // 1. Change3 and Change2 were merged at the same time, but Change3 ID > Change2 ID.
+    // 2. Change1 ID < Change3 ID & Change2 ID but it was merged last.
+    assertQuery("mergedbefore:2009-10-06", change1, change3, change2);
+    assertQuery("mergedafter:2009-09-30", change1, change3, change2);
+    assertQuery("status:merged", change1, change3, change2);
   }
 
   @Test
@@ -3055,6 +3301,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 +3339,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 +3351,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 +3368,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 +3401,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
@@ -3505,6 +3835,62 @@
     return c.getLastUpdatedOn().getTime();
   }
 
+  // Get the last  updated time from ChangeApi
+  protected long lastUpdatedMsApi(Change c) throws Exception {
+    return gApi.changes().id(c.getChangeId()).get().updated.getTime();
+  }
+
+  protected void approve(Change change) throws Exception {
+    gApi.changes().id(change.getChangeId()).current().review(ReviewInput.approve());
+  }
+
+  protected void submit(Change change) throws Exception {
+    approve(change);
+    gApi.changes().id(change.getChangeId()).current().submit();
+  }
+
+  /**
+   * Generates a search query to test {@link com.google.gerrit.index.query.Matchable} implementation
+   * of change {@link IndexPredicate}
+   *
+   * <p>This code path requires triggering the condition, when
+   *
+   * <ol>
+   *   <li>The query is rewritten into multiple {@link IndexedChangeQuery} by {@link
+   *       com.google.gerrit.server.index.change.ChangeIndexRewriter#rewrite}
+   *   <li>The changes are returned from the index by the first {@link IndexedChangeQuery}
+   *   <li>Then constrained in {@link com.google.gerrit.index.query.AndSource#match} by applying all
+   *       parsed predicates from the search query
+   *   <li>Thus, the rest of {@link IndexedChangeQuery} work as filters on the index results, see
+   *       {@link IndexedChangeQuery#match}
+   * </ol>
+   *
+   * The constructed query only constrains by the passed searchTerm for the operator that is being
+   * tested (for all changes without a reviewer):
+   *
+   * <ul>
+   *   <li>The search term 'status:new OR status:merged OR status:abandoned' is used to return all
+   *       changes from the search index.
+   *   <li>The non-indexed search term 'reviewerin:"Empty Group"' is only used to make the right AND
+   *       operand work as a filter (not a data source).
+   *   <li>See how it is rewritten in {@link
+   *       com.google.gerrit.server.index.change.ChangeIndexRewriterTest#threeLevelTreeWithMultipleSources}
+   * </ul>
+   *
+   * @param searchTerm change search term that maps to {@link IndexPredicate} and needs to be tested
+   *     as filter
+   * @return a search query that allows to test the {@code searchTerm} as a filter.
+   */
+  protected String makeIndexedPredicateFilterQuery(String searchTerm) throws Exception {
+    String emptyGroupName = "Empty Group";
+    if (gApi.groups().query(emptyGroupName).get().isEmpty()) {
+      createGroup(emptyGroupName, "Administrators");
+    }
+    String queryPattern =
+        "(status:new OR status:merged OR status:abandoned) AND (reviewerin:\"%s\" OR %s)";
+    return String.format(queryPattern, emptyGroupName, searchTerm);
+  }
+
   private void addComment(int changeId, String message, Boolean unresolved) throws Exception {
     ReviewInput input = new ReviewInput();
     ReviewInput.CommentInput comment = new ReviewInput.CommentInput();
diff --git a/javatests/com/google/gerrit/server/query/change/BUILD b/javatests/com/google/gerrit/server/query/change/BUILD
index 0258e5d..43b9690 100644
--- a/javatests/com/google/gerrit/server/query/change/BUILD
+++ b/javatests/com/google/gerrit/server/query/change/BUILD
@@ -19,7 +19,6 @@
         "//java/com/google/gerrit/acceptance/config",
         "//java/com/google/gerrit/acceptance/testsuite/project",
         "//java/com/google/gerrit/common:annotations",
-        "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/httpd",
diff --git a/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java b/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
index d0398e9..ebb2f38 100644
--- a/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
+++ b/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.ComparisonType;
@@ -40,6 +41,7 @@
 import com.google.gerrit.server.patch.PatchListEntry;
 import com.google.gerrit.server.patch.PatchListKey;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.restapi.change.CommentPorter.Metrics;
 import com.google.gerrit.truth.NullAwareCorrespondence;
 import java.sql.Timestamp;
 import java.util.Arrays;
@@ -61,6 +63,8 @@
   @Mock private PatchListCache patchListCache;
   @Mock private CommentsUtil commentsUtil;
 
+  private static final CommentPorter.Metrics metrics = new Metrics(new DisabledMetricMaker());
+
   private int uuidCounter = 0;
 
   @Test
@@ -72,7 +76,7 @@
     PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil, metrics);
     HumanComment comment = createComment(patchset1.id(), "myFile");
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
@@ -94,7 +98,7 @@
     PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil, metrics);
     HumanComment comment = createComment(patchset1.id(), "myFile");
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
@@ -116,7 +120,7 @@
     PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil, metrics);
     HumanComment comment = createComment(patchset1.id(), "myFile");
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenThrow(IllegalStateException.class);
@@ -136,7 +140,7 @@
     PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil, metrics);
     HumanComment comment = createComment(patchset1.id(), "myFile");
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
@@ -161,7 +165,7 @@
     PatchSet patchset3 = createPatchset(PatchSet.id(changeId, 3));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2, patchset3);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil, metrics);
     // Place the comments on different patchsets to have two different diff requests.
     HumanComment comment1 = createComment(patchset1.id(), "myFile");
     HumanComment comment2 = createComment(patchset2.id(), "myFile");
@@ -191,7 +195,7 @@
     // Leave out patchset 1 (e.g. reserved for draft patchsets in the past).
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset2);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil, metrics);
     HumanComment comment = createComment(patchset1.id(), "myFile");
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
diff --git a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
index 5cefe74..b3e0c56 100644
--- a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
+++ b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
@@ -25,6 +25,7 @@
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Set;
 import java.util.stream.Collectors;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -33,7 +34,6 @@
 @RunWith(JUnit4.class)
 public class ListChangeCommentsTest {
 
-  @SuppressWarnings("TruthIncompatibleType")
   @Test
   public void commentsLinkedToChangeMessagesIgnoreGerritAutoGenTaggedMessages() {
     /* Comments should not be linked to Gerrit's autogenerated messages */
@@ -55,10 +55,10 @@
         .isEqualTo(getChangeMessage(changeMessages, "cm3").getKey().uuid());
 
     // Make sure no comment is linked to the auto-gen message
-    assertThat(comments.stream().map(c -> c.changeMessageId).collect(Collectors.toSet()))
-        .doesNotContain(
-            /* expected: String, actual: ChangeMessage */ getChangeMessage(
-                changeMessages, "cmAutoGenByGerrit"));
+    Set<String> changeMessageIds =
+        comments.stream().map(c -> c.changeMessageId).collect(Collectors.toSet());
+    assertThat(changeMessageIds)
+        .doesNotContain(getChangeMessage(changeMessages, "cmAutoGenByGerrit").getKey().uuid());
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/rules/BUILD b/javatests/com/google/gerrit/server/rules/BUILD
index 250b0ce..5c57ede 100644
--- a/javatests/com/google/gerrit/server/rules/BUILD
+++ b/javatests/com/google/gerrit/server/rules/BUILD
@@ -7,7 +7,6 @@
     resources = ["//prologtests:gerrit_common_test"],
     runtime_deps = ["//prolog:gerrit-prolog-common"],
     deps = [
-        "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/project/testing:project-test-util",
diff --git a/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java b/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
index cf5e8fe..e5dd817 100644
--- a/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
+++ b/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
@@ -34,12 +34,12 @@
 public class IgnoreSelfApprovalRuleTest {
   private static final Change.Id CHANGE_ID = Change.id(100);
   private static final PatchSet.Id PS_ID = PatchSet.id(CHANGE_ID, 1);
-  private static final LabelType VERIFIED = makeLabel("Verified");
+  private static final LabelType VERIFIED = makeLabel(LabelId.VERIFIED);
   private static final Account.Id USER1 = makeAccount(100001);
 
   @Test
   public void filtersByLabel() {
-    LabelType codeReview = makeLabel("Code-Review");
+    LabelType codeReview = makeLabel(LabelId.CODE_REVIEW);
     PatchSetApproval approvalVerified = makeApproval(VERIFIED.getLabelId(), USER1, 2);
     PatchSetApproval approvalCr = makeApproval(codeReview.getLabelId(), USER1, 2);
 
diff --git a/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java b/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java
index 8622b32..a5357e1 100644
--- a/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java
+++ b/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java
@@ -16,13 +16,14 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.gerrit.entities.LabelId;
 import org.junit.Test;
 
 public class PrologRuleEvaluatorTest {
 
   @Test
   public void validLabelNamesAreKept() {
-    for (String labelName : new String[] {"Verified", "Code-Review"}) {
+    for (String labelName : new String[] {LabelId.VERIFIED, LabelId.CODE_REVIEW}) {
       assertThat(PrologRuleEvaluator.checkLabelName(labelName)).isEqualTo(labelName);
     }
   }
diff --git a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
index d6c5b5a..e6a6497 100644
--- a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
+++ b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.GroupUuid;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.Sequences;
@@ -88,7 +89,7 @@
     expectedConfig.fromText(getDefaultAllProjectsWithAllDefaultSections());
 
     GroupReference adminsGroup = createGroupReference("Administrators");
-    GroupReference batchUsersGroup = createGroupReference("Service Users");
+    GroupReference batchUsersGroup = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
     AllProjectsInput allProjectsInput =
         AllProjectsInput.builder()
             .administratorsGroup(adminsGroup)
diff --git a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
index 01a44f3..fc6b412 100644
--- a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
+++ b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -82,7 +83,7 @@
 
   @Test
   public void groupIsCreatedWhenSchemaIsCreated() throws Exception {
-    assertThat(hasGroup("Service Users")).isTrue();
+    assertThat(hasGroup(ServiceUserClassifier.SERVICE_USERS)).isTrue();
     assertThat(hasGroup("Non-Interactive Users")).isFalse();
   }
 
diff --git a/javatests/com/google/gerrit/server/update/BUILD b/javatests/com/google/gerrit/server/update/BUILD
index e175b95..4fe4ab04 100644
--- a/javatests/com/google/gerrit/server/update/BUILD
+++ b/javatests/com/google/gerrit/server/update/BUILD
@@ -10,7 +10,6 @@
     ],
     deps = [
         "//java/com/google/gerrit/common:annotations",
-        "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server",
diff --git a/lib/LICENSE-PublicDomain b/lib/LICENSE-PublicDomain
new file mode 100644
index 0000000..8a71ce0
--- /dev/null
+++ b/lib/LICENSE-PublicDomain
@@ -0,0 +1 @@
+This software has been placed in the public domain by its author(s).
diff --git a/lib/auto/BUILD b/lib/auto/BUILD
index 1da7f50..18b9b91 100644
--- a/lib/auto/BUILD
+++ b/lib/auto/BUILD
@@ -27,6 +27,21 @@
     ],
 )
 
+java_plugin(
+    name = "auto-value-gson-plugin",
+    processor_class = "com.ryanharter.auto.value.gson.factory.AutoValueGsonAdapterFactoryProcessor",
+    deps = [
+        "@auto-value-annotations//jar",
+        "@auto-value-gson-extension//jar",
+        "@auto-value-gson-factory//jar",
+        "@auto-value-gson-runtime//jar",
+        "@auto-value//jar",
+        "@autotransient//jar",
+        "@gson//jar",
+        "@javapoet//jar",
+    ],
+)
+
 java_library(
     name = "auto-value",
     data = ["//lib:LICENSE-Apache2.0"],
@@ -50,3 +65,17 @@
     visibility = ["//visibility:public"],
     exports = ["@auto-value-annotations//jar"],
 )
+
+java_library(
+    name = "auto-value-gson",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exported_plugins = [
+        ":auto-value-gson-plugin",
+    ],
+    visibility = ["//visibility:public"],
+    exports = [
+        "@auto-value-gson-extension//jar",
+        "@auto-value-gson-factory//jar",
+        "@auto-value-gson-runtime//jar",
+    ],
+)
diff --git a/lib/guava.bzl b/lib/guava.bzl
deleted file mode 100644
index 4de39cb..0000000
--- a/lib/guava.bzl
+++ /dev/null
@@ -1,5 +0,0 @@
-GUAVA_VERSION = "29.0-jre"
-
-GUAVA_BIN_SHA1 = "801142b4c3d0f0770dd29abea50906cacfddd447"
-
-GUAVA_DOC_URL = "https://google.github.io/guava/releases/" + GUAVA_VERSION + "/api/docs/"
diff --git a/lib/guice/BUILD b/lib/guice/BUILD
index 14179d6..f73984b 100644
--- a/lib/guice/BUILD
+++ b/lib/guice/BUILD
@@ -1,9 +1,4 @@
-load("@rules_java//java:defs.bzl", "java_import", "java_library")
-
-java_import(
-    name = "guice-library-no-aop",
-    jars = ["@guice-library-no-aop//file"],
-)
+load("@rules_java//java:defs.bzl", "java_library")
 
 java_library(
     name = "guice",
@@ -19,7 +14,8 @@
     name = "guice-library",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = [":guice-library-no-aop"],
+    exports = ["@guice-library//jar"],
+    runtime_deps = ["aopalliance"],
 )
 
 java_library(
@@ -39,6 +35,12 @@
 )
 
 java_library(
+    name = "aopalliance",
+    data = ["//lib:LICENSE-PublicDomain"],
+    exports = ["@aopalliance//jar"],
+)
+
+java_library(
     name = "javax_inject",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
diff --git a/lib/nongoogle_test.sh b/lib/nongoogle_test.sh
index 9ec19a3..f596164 100755
--- a/lib/nongoogle_test.sh
+++ b/lib/nongoogle_test.sh
@@ -18,25 +18,33 @@
 dropwizard-core
 duct-tape
 eddsa
-elasticsearch-rest-client
 flogger
 flogger-log4j-backend
 flogger-system-backend
+guava
+guice-assistedinject
+guice-library
+guice-servlet
 httpasyncclient
 httpcore-nio
 j2objc
 jackson-annotations
 jackson-core
+jimfs
 jna
 jruby
 mina-core
 nekohtml
 objenesis
 openid-consumer
+soy
 sshd-mina
 sshd-osgi
 testcontainers
-testcontainers-elasticsearch
+truth
+truth-java8-extension
+truth-liteproto-extension
+truth-proto-extension
 tukaani-xz
 visible-assertions
 xerces
diff --git a/modules/jgit b/modules/jgit
index 5d925ec..9bfb0f3 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit 5d925ecbb3d0977c586f0001baf20aff12823de9
+Subproject commit 9bfb0f3a4ec856dcbebb477a1ee8803a3c47c194
diff --git a/package.json b/package.json
index 70f290b..fc4161b 100644
--- a/package.json
+++ b/package.json
@@ -2,22 +2,26 @@
   "name": "gerrit",
   "version": "3.1.0-SNAPSHOT",
   "description": "Gerrit Code Review",
-  "dependencies": {},
+  "dependencies": {
+    "@bazel/rollup": "^3.2.0",
+    "@bazel/terser": "^3.2.0",
+    "@bazel/typescript": "^3.2.0"
+  },
   "devDependencies": {
-    "@bazel/rollup": "^2.0.0",
-    "@bazel/terser": "^2.0.0",
-    "@bazel/typescript": "^2.0.0",
-    "eslint": "^6.6.0",
-    "eslint-config-google": "^0.13.0",
-    "eslint-plugin-html": "^6.0.0",
-    "eslint-plugin-import": "^2.20.1",
-    "eslint-plugin-jsdoc": "^19.2.0",
-    "eslint-plugin-prettier": "^3.1.3",
-    "gts": "^2.0.2",
+    "@typescript-eslint/eslint-plugin": "^4.11.0",
+    "eslint": "^7.16.0",
+    "eslint-config-google": "^0.14.0",
+    "eslint-plugin-html": "^6.1.1",
+    "eslint-plugin-import": "^2.22.1",
+    "eslint-plugin-jsdoc": "^30.7.9",
+    "eslint-plugin-node": "^11.1.0",
+    "eslint-plugin-prettier": "^3.3.0",
+    "gts": "^3.0.3",
     "polymer-cli": "^1.9.11",
     "prettier": "2.0.5",
+    "rollup": "^2.3.4",
     "terser": "^4.8.0",
-    "typescript": "3.9.5"
+    "typescript": "4.0.5"
   },
   "scripts": {
     "clean": "git clean -fdx && bazel clean --expunge",
@@ -29,8 +33,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/codemirror-editor b/plugins/codemirror-editor
index 7357ab4..740c35a 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit 7357ab473599d16ae33cc982bbd65472f08c2dd6
+Subproject commit 740c35ae36f44748b3c91e60ee7dcb2fb6e99549
diff --git a/plugins/delete-project b/plugins/delete-project
index 60ce67d..bfe159d 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit 60ce67dd53ad64c33a2c34aae31e9ee823979109
+Subproject commit bfe159d3007db0f07e967473b53f679ba8f432df
diff --git a/plugins/download-commands b/plugins/download-commands
index 87e3930..5bd359c 160000
--- a/plugins/download-commands
+++ b/plugins/download-commands
@@ -1 +1 @@
-Subproject commit 87e3930cea7c06aea454998abdddf6515a9f103b
+Subproject commit 5bd359c08e10b93d2c08762f75cde01a14e45fc6
diff --git a/plugins/gitiles b/plugins/gitiles
index 584360d..b196dd5 160000
--- a/plugins/gitiles
+++ b/plugins/gitiles
@@ -1 +1 @@
-Subproject commit 584360d050f5adf8317ebc078a874da9c4316bd0
+Subproject commit b196dd5b6fcfd50518a6625a64cb93424c084620
diff --git a/plugins/replication b/plugins/replication
index 2f69b53..14766e7 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 2f69b53b8c64b696d6819b4355ea3ac76d8f1293
+Subproject commit 14766e75f91886ab48951035d59a78c8c3f07471
diff --git a/plugins/singleusergroup b/plugins/singleusergroup
index 30ca9c1..09623b9 160000
--- a/plugins/singleusergroup
+++ b/plugins/singleusergroup
@@ -1 +1 @@
-Subproject commit 30ca9c1fa624b7389703dd8f8d35cff778e60d83
+Subproject commit 09623b9432d360060f88ae48fb3386e374ca29c0
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 2266ba0..2fb9e5c 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -74,6 +74,14 @@
 
 More information for installing and using nodejs rules can be found here https://bazelbuild.github.io/rules_nodejs/install.html
 
+### Upgrade to @bazel-scoped packages
+
+It might be necessary to run this command to upgrade to major `rules_nodejs` release:
+
+```sh
+yarn remove @bazel/...
+```
+
 ## Setup typescript support in the IDE
 
 Modern IDE should automatically handle typescript settings from the 
@@ -187,6 +195,9 @@
 npm run test:debug async-foreach-behavior_test.js
 ```
 
+When converting a test file to typescript, the command for running tests is
+still using the .js suffix and not the new .ts suffix.
+
 Commands `test:debug` and `test:single` assumes that compiled code is located
 in the `./ts-out/polygerrit-ui/app` directory. It's up to you how to achieve it.
 For example, the following options are possible:
@@ -293,7 +304,7 @@
 existing helpers to create an object with all required properties:
 ```
 // Before:
-sinon.stub(element.$.restAPI, 'getPreferences').returns(
+sinon.stub(element.restApiService, 'getPreferences').returns(
     Promise.resolve({default_diff_view: 'UNIFIED'}));
 
 // After:
@@ -423,12 +434,10 @@
     .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({}); },
-});
+stubRestApi('getDiffComments').returns(Promise.resolve({}));
+stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
+stubRestApi('_fetchSharedCacheURL').returns(Promise.resolve({}));
 ```
 
 In such cases, validate the input and output of a stub/fake method. Quite often
@@ -440,61 +449,9 @@
   // 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,
-  ...
+  stubRestApi('_fetchSharedCacheURL').returns(Promise.resolve({} as ParsedJSON));
 });
 ```
 
diff --git a/polygerrit-ui/app/.eslintignore b/polygerrit-ui/app/.eslintignore
index 16ea228..087a049 100644
--- a/polygerrit-ui/app/.eslintignore
+++ b/polygerrit-ui/app/.eslintignore
@@ -1,3 +1,4 @@
 **/node_modules
 **/rollup.config.js
 node_modules_licenses
+!.eslintrc-bazel.js
diff --git a/polygerrit-ui/app/.eslintrc-bazel.js b/polygerrit-ui/app/.eslintrc-bazel.js
index 977eb45..9a51242 100644
--- a/polygerrit-ui/app/.eslintrc-bazel.js
+++ b/polygerrit-ui/app/.eslintrc-bazel.js
@@ -20,7 +20,7 @@
 // for node_modules.
 
 function getBazelSettings() {
-  const runFilesDir = process.env["RUNFILES_DIR"];
+  const runFilesDir = process.env['RUNFILES_DIR'];
   if (!runFilesDir) {
     // eslint is executed with 'bazel run ...' to fix the source code. It runs
     // against real source code, no special paths for node_modules is set.
@@ -28,18 +28,18 @@
   }
   // eslint is executed with 'bazel test...'. Set path to required node_modules
   return {
-    "import/resolver": {
-      "node": {
-        "paths": [
+    'import/resolver': {
+      node: {
+        paths: [
           `${runFilesDir}/ui_npm/node_modules`,
-          `${runFilesDir}/ui_dev_npm/node_modules`
-        ]
-      }
-    }
+          `${runFilesDir}/ui_dev_npm/node_modules`,
+        ],
+      },
+    },
   };
 }
 
 module.exports = {
-  "extends": "./.eslintrc.js",
-  "settings": getBazelSettings(),
+  extends: './.eslintrc.js',
+  settings: getBazelSettings(),
 };
diff --git a/polygerrit-ui/app/.eslintrc.js b/polygerrit-ui/app/.eslintrc.js
index c5dde38..a3da3cf 100644
--- a/polygerrit-ui/app/.eslintrc.js
+++ b/polygerrit-ui/app/.eslintrc.js
@@ -20,219 +20,225 @@
 const path = require('path');
 
 module.exports = {
-  "extends": ["eslint:recommended", "google"],
-  "parserOptions": {
-    "ecmaVersion": 9,
-    "sourceType": "module"
+  extends: ['eslint:recommended', 'google'],
+  parserOptions: {
+    ecmaVersion: 9,
+    sourceType: 'module',
   },
-  "env": {
-    "browser": true,
-    "es6": true
+  env: {
+    browser: true,
+    es6: true,
   },
-  "rules": {
+  rules: {
     // https://eslint.org/docs/rules/no-confusing-arrow
-    "no-confusing-arrow": "error",
+    'no-confusing-arrow': 'error',
     // https://eslint.org/docs/rules/newline-per-chained-call
-    "newline-per-chained-call": ["error", {"ignoreChainWithDepth": 2}],
+    'newline-per-chained-call': ['error', {ignoreChainWithDepth: 2}],
     // https://eslint.org/docs/rules/arrow-body-style
-    "arrow-body-style": ["error", "as-needed",
-      {"requireReturnForObjectLiteral": true}],
+    'arrow-body-style': ['error', 'as-needed',
+      {requireReturnForObjectLiteral: true}],
     // https://eslint.org/docs/rules/arrow-parens
-    "arrow-parens": ["error", "as-needed"],
+    'arrow-parens': ['error', 'as-needed'],
     // https://eslint.org/docs/rules/block-spacing
-    "block-spacing": ["error", "always"],
+    'block-spacing': ['error', 'always'],
     // https://eslint.org/docs/rules/brace-style
-    "brace-style": ["error", "1tbs", {"allowSingleLine": true}],
+    'brace-style': ['error', '1tbs', {allowSingleLine: true}],
     // https://eslint.org/docs/rules/camelcase
-    "camelcase": "off",
+    'camelcase': 'off',
     // https://eslint.org/docs/rules/comma-dangle
-    "comma-dangle": ["error", {
-      "arrays": "always-multiline",
-      "objects": "always-multiline",
-      "imports": "always-multiline",
-      "exports": "always-multiline",
-      "functions": "never"
+    'comma-dangle': ['error', {
+      arrays: 'always-multiline',
+      objects: 'always-multiline',
+      imports: 'always-multiline',
+      exports: 'always-multiline',
+      functions: 'never',
     }],
     // https://eslint.org/docs/rules/eol-last
-    "eol-last": "off",
+    'eol-last': 'off',
+    'guard-for-in': 'error',
     // https://eslint.org/docs/rules/indent
-    "indent": ["error", 2, {
-      "MemberExpression": 2,
-      "FunctionDeclaration": {"body": 1, "parameters": 2},
-      "FunctionExpression": {"body": 1, "parameters": 2},
-      "CallExpression": {"arguments": 2},
-      "ArrayExpression": 1,
-      "ObjectExpression": 1,
-      "SwitchCase": 1
+    'indent': ['error', 2, {
+      MemberExpression: 2,
+      FunctionDeclaration: {body: 1, parameters: 2},
+      FunctionExpression: {body: 1, parameters: 2},
+      CallExpression: {arguments: 2},
+      ArrayExpression: 1,
+      ObjectExpression: 1,
+      SwitchCase: 1,
     }],
     // https://eslint.org/docs/rules/keyword-spacing
-    "keyword-spacing": ["error", {"after": true, "before": true}],
+    'keyword-spacing': ['error', {after: true, before: true}],
     // https://eslint.org/docs/rules/lines-between-class-members
-    "lines-between-class-members": ["error", "always"],
+    'lines-between-class-members': ['error', 'always'],
     // https://eslint.org/docs/rules/max-len
-    "max-len": [
-      "error",
+    'max-len': [
+      'error',
       80,
       2,
       {
-        "ignoreComments": true,
-        "ignorePattern": "^import .*;$"
-      }
+        ignoreComments: true,
+        ignorePattern: '^import .*;$',
+      },
     ],
     // https://eslint.org/docs/rules/new-cap
-    "new-cap": ["error", {
-      "capIsNewExceptions": ["Polymer", "GestureEventListeners"],
-      "capIsNewExceptionPattern": "^.*Mixin$"
+    'new-cap': ['error', {
+      capIsNewExceptions: ['Polymer', 'GestureEventListeners'],
+      capIsNewExceptionPattern: '^.*Mixin$',
     }],
     // https://eslint.org/docs/rules/no-console
-    "no-console": ["error", { allow: ["warn", "error", "info", "assert", "group", "groupEnd"] }],
+    'no-console': [
+      'error',
+      {allow: ['warn', 'error', 'info', 'assert', 'group', 'groupEnd']},
+    ],
     // https://eslint.org/docs/rules/no-multiple-empty-lines
-    "no-multiple-empty-lines": ["error", {"max": 1}],
+    'no-multiple-empty-lines': ['error', {max: 1}],
     // https://eslint.org/docs/rules/no-prototype-builtins
-    "no-prototype-builtins": "off",
+    'no-prototype-builtins': 'off',
     // https://eslint.org/docs/rules/no-redeclare
-    "no-redeclare": "off",
+    'no-redeclare': 'off',
     // https://eslint.org/docs/rules/no-trailing-spaces
-    "no-trailing-spaces": "error",
+    'no-trailing-spaces': 'error',
     // https://eslint.org/docs/rules/no-irregular-whitespace
-    "no-irregular-whitespace": "error",
+    'no-irregular-whitespace': 'error',
     // https://eslint.org/docs/rules/array-callback-return
-    "array-callback-return": ['error', { allowImplicit: true }],
+    'array-callback-return': ['error', {allowImplicit: true}],
     // https://eslint.org/docs/rules/no-restricted-syntax
-    "no-restricted-syntax": [
-      "error",
+    'no-restricted-syntax': [
+      'error',
       {
-        "selector": "ExpressionStatement > CallExpression > MemberExpression[object.name='test'][property.name='only']",
-        "message": "Remove test.only."
+        selector: 'ExpressionStatement > CallExpression > ' +
+            'MemberExpression[object.name=\'test\'][property.name=\'only\']',
+        message: 'Remove test.only.',
       },
       {
-        "selector": "ExpressionStatement > CallExpression > MemberExpression[object.name='suite'][property.name='only']",
-        "message": "Remove suite.only."
-      }
+        selector: 'ExpressionStatement > CallExpression > ' +
+            'MemberExpression[object.name=\'suite\'][property.name=\'only\']',
+        message: 'Remove suite.only.',
+      },
     ],
     // no-undef disables global variable.
     // "globals" declares allowed global variables.
     // https://eslint.org/docs/rules/no-undef
-    "no-undef": ["error"],
+    'no-undef': ['error'],
     // https://eslint.org/docs/rules/no-useless-escape
-    "no-useless-escape": "off",
+    'no-useless-escape': 'off',
     // https://eslint.org/docs/rules/no-var
-    "no-var": "error",
+    'no-var': 'error',
     // https://eslint.org/docs/rules/operator-linebreak
-    "operator-linebreak": "off",
+    'operator-linebreak': 'off',
     // https://eslint.org/docs/rules/object-shorthand
-    "object-shorthand": ["error", "always"],
+    'object-shorthand': ['error', 'always'],
     // https://eslint.org/docs/rules/padding-line-between-statements
-    "padding-line-between-statements": [
-      "error",
+    'padding-line-between-statements': [
+      'error',
       {
-        "blankLine": "always",
-        "prev": "class",
-        "next": "*"
+        blankLine: 'always',
+        prev: 'class',
+        next: '*',
       },
       {
-        "blankLine": "always",
-        "prev": "*",
-        "next": "class"
-      }
+        blankLine: 'always',
+        prev: '*',
+        next: 'class',
+      },
     ],
     // https://eslint.org/docs/rules/prefer-arrow-callback
-    "prefer-arrow-callback": "error",
+    'prefer-arrow-callback': 'error',
     // https://eslint.org/docs/rules/prefer-const
-    "prefer-const": "error",
+    'prefer-const': 'error',
     // https://eslint.org/docs/rules/prefer-promise-reject-errors
-    "prefer-promise-reject-errors": "error",
+    'prefer-promise-reject-errors': 'error',
     // https://eslint.org/docs/rules/prefer-spread
-    "prefer-spread": "error",
+    'prefer-spread': 'error',
     // https://eslint.org/docs/rules/prefer-object-spread
-    "prefer-object-spread": "error",
+    'prefer-object-spread': 'error',
     // https://eslint.org/docs/rules/quote-props
-    "quote-props": ["error", "consistent-as-needed"],
+    'quote-props': ['error', 'consistent-as-needed'],
     // https://eslint.org/docs/rules/semi
-    "semi": ["error", "always"],
+    'semi': ['error', 'always'],
     // https://eslint.org/docs/rules/template-curly-spacing
-    "template-curly-spacing": "error",
+    'template-curly-spacing': 'error',
 
     // https://eslint.org/docs/rules/require-jsdoc
-    "require-jsdoc": 0,
+    'require-jsdoc': 0,
     // https://eslint.org/docs/rules/valid-jsdoc
-    "valid-jsdoc": 0,
+    'valid-jsdoc': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-alignment
-    "jsdoc/check-alignment": 2,
+    'jsdoc/check-alignment': 2,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-examples
-    "jsdoc/check-examples": 0,
+    'jsdoc/check-examples': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-indentation
-    "jsdoc/check-indentation": 0,
+    'jsdoc/check-indentation': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-param-names
-    "jsdoc/check-param-names": 0,
+    'jsdoc/check-param-names': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-syntax
-    "jsdoc/check-syntax": 0,
+    'jsdoc/check-syntax': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-tag-names
-    "jsdoc/check-tag-names": 0,
+    'jsdoc/check-tag-names': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-types
-    "jsdoc/check-types": 0,
+    'jsdoc/check-types': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-implements-on-classes
-    "jsdoc/implements-on-classes": 2,
+    'jsdoc/implements-on-classes': 2,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-match-description
-    "jsdoc/match-description": 0,
+    'jsdoc/match-description': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-newline-after-description
-    "jsdoc/newline-after-description": 2,
+    'jsdoc/newline-after-description': 2,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-no-types
-    "jsdoc/no-types": 0,
+    'jsdoc/no-types': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-no-undefined-types
-    "jsdoc/no-undefined-types": 0,
+    'jsdoc/no-undefined-types': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-description
-    "jsdoc/require-description": 0,
+    'jsdoc/require-description': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-description-complete-sentence
-    "jsdoc/require-description-complete-sentence": 0,
+    'jsdoc/require-description-complete-sentence': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-example
-    "jsdoc/require-example": 0,
+    'jsdoc/require-example': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-hyphen-before-param-description
-    "jsdoc/require-hyphen-before-param-description": 0,
+    'jsdoc/require-hyphen-before-param-description': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-jsdoc
-    "jsdoc/require-jsdoc": 0,
+    'jsdoc/require-jsdoc': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-param
-    "jsdoc/require-param": 0,
+    'jsdoc/require-param': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-param-description
-    "jsdoc/require-param-description": 0,
+    'jsdoc/require-param-description': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-param-name
-    "jsdoc/require-param-name": 2,
+    'jsdoc/require-param-name': 2,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-returns
-    "jsdoc/require-returns": 0,
+    'jsdoc/require-returns': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-returns-check
-    "jsdoc/require-returns-check": 0,
+    'jsdoc/require-returns-check': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-returns-description
-    "jsdoc/require-returns-description": 0,
+    'jsdoc/require-returns-description': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-valid-types
-    "jsdoc/valid-types": 2,
+    'jsdoc/valid-types': 2,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-file-overview
-    "jsdoc/require-file-overview": ["error", {
-      "tags": {
-        "license": {
-          "mustExist": true,
-          "preventDuplicates": true
-        }
-      }
+    'jsdoc/require-file-overview': ['error', {
+      tags: {
+        license: {
+          mustExist: true,
+          preventDuplicates: true,
+        },
+      },
     }],
     // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-self-import.md
-    "import/no-self-import": 2,
+    'import/no-self-import': 2,
     // The no-cycle rule is slow, because it doesn't cache dependencies.
     // Disable it.
     // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-cycle.md
-    "import/no-cycle": 0,
+    'import/no-cycle': 0,
     // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-useless-path-segments.md
-    "import/no-useless-path-segments": 2,
+    'import/no-useless-path-segments': 2,
     // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-unused-modules.md
-    "import/no-unused-modules": 2,
+    'import/no-unused-modules': 2,
     // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-default-export.md
-    "import/no-default-export": 2,
+    'import/no-default-export': 2,
     // Prevents certain identifiers being used.
     // Prefer flush() over flushAsynchronousOperations().
-    "id-blacklist": ["error", "flushAsynchronousOperations"],
+    'id-blacklist': ['error', 'flushAsynchronousOperations'],
   },
 
   // List of allowed globals in all files
-  "globals": {
+  globals: {
     // Polygerrit global variables.
     // You must not add anything new in this list!
     // Instead export variables from modules
@@ -240,150 +246,168 @@
     // Global variables from 3rd party libraries.
     // You should not add anything in this list, always try to import
     // If import is not possible - you can extend this list
-    "ShadyCSS": "readonly",
-    "linkify": "readonly",
-    "security": "readonly",
+    ShadyCSS: 'readonly',
+    linkify: 'readonly',
+    security: 'readonly',
   },
-  "overrides": [
+  overrides: [
     {
-      // .js-only rules
-      "files": ["**/*.js"],
-      "rules": {
-        // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-param-type
-        "jsdoc/require-param-type": 2,
-        // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-returns-type
-        "jsdoc/require-returns-type": 2,
-        // The rule is required for .js files only, because typescript compiler
-        // always checks import.
-        "import/no-unresolved": 2,
-        "import/named": 2,
+      files: ['.eslintrc.js', '.eslintrc-bazel.js'],
+      env: {
+        browser: false,
+        es6: true,
+        node: true,
       },
-      "globals": {
-        "goog": "readonly",
-      }
     },
     {
-      "files": ["**/*.ts"],
-      "extends": [require.resolve("gts/.eslintrc.json")],
-      "rules": {
-        "no-restricted-imports": ["error", {
-          name: "@polymer/decorators/lib/decorators",
-          message: "Use @polymer/decorators instead",
+      // .js-only rules
+      files: ['**/*.js'],
+      rules: {
+        // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-param-type
+        'jsdoc/require-param-type': 2,
+        // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-returns-type
+        'jsdoc/require-returns-type': 2,
+        // The rule is required for .js files only, because typescript compiler
+        // always checks import.
+        'import/no-unresolved': 2,
+        'import/named': 2,
+      },
+      globals: {
+        goog: 'readonly',
+      },
+    },
+    {
+      files: ['**/*.ts'],
+      extends: [require.resolve('gts/.eslintrc.json')],
+      rules: {
+        'no-restricted-imports': ['error', {
+          name: '@polymer/decorators/lib/decorators',
+          message: 'Use @polymer/decorators instead',
         }],
+        '@typescript-eslint/no-explicit-any': 'error',
         // See https://github.com/GoogleChromeLabs/shadow-selection-polyfill/issues/9
-        "@typescript-eslint/ban-ts-ignore": "off",
+        '@typescript-eslint/ban-ts-comment': 'off',
         // The following rules is required to match internal google rules
-        "@typescript-eslint/restrict-plus-operands": "error",
+        '@typescript-eslint/restrict-plus-operands': 'error',
+        '@typescript-eslint/no-unused-vars': [
+          'error',
+          {argsIgnorePattern: '^_'},
+        ],
         // https://github.com/mysticatea/eslint-plugin-node/blob/master/docs/rules/no-unsupported-features/node-builtins.md
-        "node/no-unsupported-features/node-builtins": "off",
+        'node/no-unsupported-features/node-builtins': 'off',
         // Disable no-invalid-this for ts files, because it incorrectly reports
         // errors in some cases (see https://github.com/typescript-eslint/typescript-eslint/issues/491)
         // At the same time, we are using typescript in a strict mode and
         // it catches almost all errors related to invalid usage of this.
-        "no-invalid-this": "off",
+        'no-invalid-this': 'off',
 
-        "node/no-extraneous-import": "off",
+        'node/no-extraneous-import': 'off',
 
         // Typescript already checks for undef
-        "no-undef": "off",
+        'no-undef': 'off',
 
-        "jsdoc/no-types": 2,
+        'jsdoc/no-types': 2,
       },
-      "parserOptions": {
-        "project": path.resolve(__dirname, "./tsconfig_eslint.json"),
-      }
-    },
-    {
-      "files": ["*.html", "test.js", "test-infra.js"],
-      "rules": {
-        "jsdoc/require-file-overview": "off"
+      parserOptions: {
+        project: path.resolve(__dirname, './tsconfig_eslint.json'),
       },
     },
     {
-      "files": [
-        "*.html",
-        "*_test.js",
-        "a11y-test-utils.js",
+      files: ['*_test.ts'],
+      rules: {
+        '@typescript-eslint/no-explicit-any': 'off',
+      },
+    },
+    {
+      files: ['*.html', 'test.js', 'test-infra.js'],
+      rules: {
+        'jsdoc/require-file-overview': 'off',
+      },
+    },
+    {
+      files: [
+        '*.html',
+        '*_test.js',
+        'a11y-test-utils.js',
       ],
       // Additional global variables allowed in tests
-      "globals": {
+      globals: {
         // Global variables from 3rd party test libraries/frameworks.
         // You can extend this list if you want to use other global
         // variables from these libraries and import is not possible
-        "MockInteractions": "readonly",
-        "_": "readonly",
-        "axs": "readonly",
-        "a11ySuite": "readonly",
-        "assert": "readonly",
-        "expect": "readonly",
-        "fixture": "readonly",
-        "flush": "readonly",
-        "flushAsynchronousOperations": "readonly",
-        "setup": "readonly",
-        "sinon": "readonly",
-        "stub": "readonly",
-        "suite": "readonly",
-        "suiteSetup": "readonly",
-        "suiteTeardown": "readonly",
-        "teardown": "readonly",
-        "test": "readonly",
-        "fixtureFromElement": "readonly",
-        "fixtureFromTemplate": "readonly",
-      }
+        MockInteractions: 'readonly',
+        _: 'readonly',
+        axs: 'readonly',
+        a11ySuite: 'readonly',
+        assert: 'readonly',
+        expect: 'readonly',
+        fixture: 'readonly',
+        flush: 'readonly',
+        setup: 'readonly',
+        sinon: 'readonly',
+        stub: 'readonly',
+        suite: 'readonly',
+        suiteSetup: 'readonly',
+        suiteTeardown: 'readonly',
+        teardown: 'readonly',
+        test: 'readonly',
+        fixtureFromElement: 'readonly',
+        fixtureFromTemplate: 'readonly',
+      },
     },
     {
-      "files": "import-href.js",
-      "globals": {
-        "HTMLImports": "readonly",
-      }
+      files: 'import-href.js',
+      globals: {
+        HTMLImports: 'readonly',
+      },
     },
     {
-      "files": ["samples/**/*.js"],
-      "globals": {
+      files: ['samples/**/*.js'],
+      globals: {
         // Settings for samples. You can add globals here if you want to use it
-        "Gerrit": "readonly",
-        "Polymer": "readonly",
-      }
+        Gerrit: 'readonly',
+        Polymer: 'readonly',
+      },
     },
     {
-      "files": ["test/functional/**/*.js"],
+      files: ['test/functional/**/*.js'],
       // Settings for functional tests. These scripts are node scripts.
       // Turn off "no-undef" to allow any global variable
-      "env": {
-        "browser": false,
-        "node": true,
-        "es6": false
+      env: {
+        browser: false,
+        node: true,
+        es6: false,
       },
-      "rules": {
-        "no-undef": "off",
-      }
+      rules: {
+        'no-undef': 'off',
+      },
     },
     {
-      "files": ["*_html.js", "gr-icons.js", "*-theme.js", "*-styles.js"],
-      "rules": {
-        "max-len": "off"
-      }
+      files: ['*_html.js', 'gr-icons.js', '*-theme.js', '*-styles.js'],
+      rules: {
+        'max-len': 'off',
+      },
     },
     {
-      "files": ["*_html.js"],
-      "rules": {
-        "prettier/prettier": ["error", {
-          "bracketSpacing": false,
-          "singleQuote": true,
-        }]
-      }
-    }
+      files: ['*_html.js'],
+      rules: {
+        'prettier/prettier': ['error', {
+          bracketSpacing: false,
+          singleQuote: true,
+        }],
+      },
+    },
   ],
-  "plugins": [
-    "html",
-    "jsdoc",
-    "import",
-    "prettier"
+  plugins: [
+    'html',
+    'jsdoc',
+    'import',
+    'prettier',
   ],
-  "settings": {
-    "html/report-bad-indent": "error",
-    "import/resolver": {
-      "node": {},
+  settings: {
+    'html/report-bad-indent': 'error',
+    'import/resolver': {
+      node: {},
       [path.resolve(__dirname, './.eslint-ts-resolver.js')]: {},
     },
   },
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index c29663c..2f83182 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -6,6 +6,7 @@
 # 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 = [
+    "api",
     "constants",
     "elements",
     "embed",
diff --git a/polygerrit-ui/app/api/README.md b/polygerrit-ui/app/api/README.md
new file mode 100644
index 0000000..550063f
--- /dev/null
+++ b/polygerrit-ui/app/api/README.md
@@ -0,0 +1,23 @@
+# API
+
+In this folder, we declare the API of various parts of the Gerrit webclient.
+There are two primary use cases for this:
+
+* apps that embed our diff viewer, gr-diff
+* Gerrit plugins that need to access some part of Gerrit to extend it
+
+Both may be built as a separate bundle, but would like to type check against
+the same types the Gerrit/gr-diff bundle uses. For this reason, this folder
+should contain only types, with the exception of enums, where having the
+value side is deemed an acceptable duplication.
+
+All types in here should use the `declare` keyword to prevent bundlers from
+renaming fields, which would break communication across separately built
+bundles. Again enums are the exception, because their keys are not referenced
+across bundles, and values will not be renamed by bundlers as they are strings.
+
+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.
+
+Gerrit types should either directly use or extend these types, so that
+breaking changes to the implementation require changes to these files.
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts b/polygerrit-ui/app/api/admin.ts
similarity index 70%
copy from polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
copy to polygerrit-ui/app/api/admin.ts
index ac59f4f..a7b549d 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
+++ b/polygerrit-ui/app/api/admin.ts
@@ -14,8 +14,16 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
 
-export const htmlTemplate = html`
-  <gr-lib-loader id="libLoader"></gr-lib-loader>
-`;
+/** Interface for menu link */
+export interface MenuLink {
+  text: string;
+  url: string;
+  capability: string | null;
+}
+
+export interface AdminPluginApi {
+  addMenuLink(text: string, url: string, capability?: string): void;
+
+  getMenuLinks(): MenuLink[];
+}
diff --git a/polygerrit-ui/app/api/annotation.ts b/polygerrit-ui/app/api/annotation.ts
new file mode 100644
index 0000000..e58bdd5
--- /dev/null
+++ b/polygerrit-ui/app/api/annotation.ts
@@ -0,0 +1,138 @@
+/**
+ * @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 {CoverageRange, GrDiffLine, Side} from './diff';
+import {StyleObject} from './styles';
+
+/**
+ * This is the callback object that Gerrit calls once for each diff. Gerrit
+ * is then responsible for styling the diff according the returned array of
+ * CoverageRanges.
+ */
+export type CoverageProvider = (
+  changeNum: number,
+  path: string,
+  basePatchNum?: number,
+  patchNum?: number,
+  /**
+   * This is a ChangeInfo object as defined here:
+   * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-info
+   * At the moment we neither want to repeat it nor add a dependency on it here.
+   * TODO: Create a dedicated smaller object for exposing a change in the plugin
+   * API. Or allow the plugin API to depend on the entire rest API.
+   */
+  change?: unknown
+) => Promise<Array<CoverageRange>>;
+
+export type AnnotationCallback = (ctx: AnnotationContext) => void;
+
+/**
+ * This object is passed to the plugin from Gerrit for each line of a diff that
+ * is being rendered. The plugin can then call annotateRange() or
+ * annotateLineNumber() to apply additional styles to the diff.
+ */
+export interface AnnotationContext {
+  /** Set by Gerrit and consumed by the plugin provided AddLayerFunc. */
+  readonly changeNum: number;
+  /** Set by Gerrit and consumed by the plugin provided AddLayerFunc. */
+  readonly path: string;
+  /** Set by Gerrit and consumed by the plugin provided AddLayerFunc. */
+  readonly line: GrDiffLine;
+  /** Set by Gerrit and consumed by the plugin provided AddLayerFunc. */
+  readonly contentEl: HTMLElement;
+  /** Set by Gerrit and consumed by the plugin provided AddLayerFunc. */
+  readonly lineNumberEl: HTMLElement;
+
+  /**
+   * Can be called by the plugin to style a part of the given line of the
+   * context.
+   *
+   * @param offset The char offset where the update starts.
+   * @param length The number of chars that the update covers.
+   * @param styleObject The style object for the range.
+   * @param side The side of the update. ('left' or 'right')
+   */
+  annotateRange(
+    offset: number,
+    length: number,
+    styleObject: StyleObject,
+    side: string
+  ): void;
+
+  /**
+   * Can be called by the plugin to style a part of the given line of the
+   * context.
+   *
+   * @param styleObject The style object for the range.
+   * @param side The side of the update. ('left' or 'right')
+   */
+  annotateLineNumber(styleObject: StyleObject, side: string): void;
+}
+
+export interface AnnotationPluginApi {
+  /**
+   * Registers a callback for applying annotations. Gerrit will call the
+   * callback for every line of every file that is rendered and pass the
+   * information about the file and line as an AnnotationContext, which also
+   * provides methods for the plugin to style the content.
+   */
+  setLayer(callback: AnnotationCallback): AnnotationPluginApi;
+
+  /**
+   * The specified function will be called when a gr-diff component is built,
+   * and feeds the returned coverage data into the diff. Optional.
+   *
+   * Be sure to call this only once and only from one plugin. Multiple coverage
+   * providers are not supported. A second call will just overwrite the
+   * provider of the first call.
+   */
+  setCoverageProvider(coverageProvider: CoverageProvider): AnnotationPluginApi;
+
+  /**
+   * Returns a checkbox HTMLElement that can be used to toggle annotations
+   * on/off. The checkbox will be initially disabled. Plugins should enable it
+   * when data is ready and should add a click handler to toggle CSS on/off.
+   *
+   * Note1: Calling this method from multiple plugins will only work for the
+   * 1st call. It will print an error message for all subsequent calls
+   * and will not invoke their onAttached functions.
+   * Note2: This method will be deprecated and eventually removed when
+   * https://bugs.chromium.org/p/gerrit/issues/detail?id=8077 is
+   * implemented.
+   *
+   * @param checkboxLabel Will be used as the label for the checkbox.
+   * Optional. "Enable" is used if this is not specified.
+   * @param onAttached The function that will be called
+   * when the checkbox is attached to the page.
+   */
+  enableToggleCheckbox(
+    checkboxLabel: string,
+    onAttached: (checkboxEl: Element | null) => void
+  ): AnnotationPluginApi;
+
+  /**
+   * For plugins notifying Gerrit about new annotations being ready to be
+   * applied for a certain range. Gerrit will then re-render the relevant lines
+   * of the diff and call back to the layer annotation function that was
+   * registered in addLayer().
+   *
+   * @param path The file path whose listeners should be notified.
+   * @param start The line where the update starts.
+   * @param end The line where the update ends.
+   * @param side The side of the update ('left' or 'right').
+   */
+  notify(path: string, start: number, end: number, side: Side): void;
+}
diff --git a/polygerrit-ui/app/api/attribute-helper.ts b/polygerrit-ui/app/api/attribute-helper.ts
new file mode 100644
index 0000000..cd52259
--- /dev/null
+++ b/polygerrit-ui/app/api/attribute-helper.ts
@@ -0,0 +1,39 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export interface AttributeHelperPluginApi {
+  /**
+   * Binds callback to property updates.
+   *
+   * @param name Property name.
+   * @return Unbind function.
+   */
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  bind(name: string, callback: (value: any) => void): () => any;
+
+  /**
+   * Get value of the property from wrapped object. Waits for the property
+   * to be initialized if it isn't defined.
+   */
+  get(name: string): Promise<unknown>;
+
+  /**
+   * Sets value and dispatches event to force notify.
+   */
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  set(name: string, value: any): void;
+}
diff --git a/polygerrit-ui/app/api/change-actions.ts b/polygerrit-ui/app/api/change-actions.ts
new file mode 100644
index 0000000..792f31e
--- /dev/null
+++ b/polygerrit-ui/app/api/change-actions.ts
@@ -0,0 +1,113 @@
+/**
+ * @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 {HttpMethod} from './rest';
+
+export interface ActionInfo {
+  method?: HttpMethod;
+  label?: string;
+  title?: string;
+  enabled?: boolean;
+}
+
+export enum ActionType {
+  CHANGE = 'change',
+  REVISION = 'revision',
+}
+
+export enum ActionPriority {
+  CHANGE = 2,
+  DEFAULT = 0,
+  PRIMARY = 3,
+  REVIEW = -3,
+  REVISION = 1,
+}
+
+export enum ChangeActions {
+  ABANDON = 'abandon',
+  DELETE = '/',
+  DELETE_EDIT = 'deleteEdit',
+  EDIT = 'edit',
+  FOLLOW_UP = 'followup',
+  IGNORE = 'ignore',
+  MOVE = 'move',
+  PRIVATE = 'private',
+  PRIVATE_DELETE = 'private.delete',
+  PUBLISH_EDIT = 'publishEdit',
+  REBASE = 'rebase',
+  REBASE_EDIT = 'rebaseEdit',
+  READY = 'ready',
+  RESTORE = 'restore',
+  REVERT = 'revert',
+  REVERT_SUBMISSION = 'revert_submission',
+  REVIEWED = 'reviewed',
+  STOP_EDIT = 'stopEdit',
+  SUBMIT = 'submit',
+  UNIGNORE = 'unignore',
+  UNREVIEWED = 'unreviewed',
+  WIP = 'wip',
+}
+
+export enum RevisionActions {
+  CHERRYPICK = 'cherrypick',
+  REBASE = 'rebase',
+  SUBMIT = 'submit',
+  DOWNLOAD = 'download',
+}
+
+export type PrimaryActionKey = ChangeActions | RevisionActions;
+
+export interface ChangeActionsPluginApi {
+  addPrimaryActionKey(key: PrimaryActionKey): void;
+
+  removePrimaryActionKey(key: string): void;
+
+  hideQuickApproveAction(): void;
+
+  setActionOverflow(type: ActionType, key: string, overflow: boolean): void;
+
+  setActionPriority(
+    type: ActionType,
+    key: string,
+    priority: ActionPriority
+  ): void;
+
+  setActionHidden(type: ActionType, key: string, hidden: boolean): void;
+
+  add(type: ActionType, label: string): string;
+
+  remove(key: string): void;
+
+  addTapListener(
+    key: string,
+    handler: EventListenerOrEventListenerObject
+  ): void;
+
+  removeTapListener(
+    key: string,
+    handler: EventListenerOrEventListenerObject
+  ): void;
+
+  setLabel(key: string, text: string): void;
+
+  setTitle(key: string, text: string): void;
+
+  setEnabled(key: string, enabled: boolean): void;
+
+  setIcon(key: string, icon: string): void;
+
+  getActionDetails(action: string): ActionInfo | undefined;
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts b/polygerrit-ui/app/api/change-metadata.ts
similarity index 80%
copy from polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
copy to polygerrit-ui/app/api/change-metadata.ts
index ac59f4f..effe661 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
+++ b/polygerrit-ui/app/api/change-metadata.ts
@@ -14,8 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
 
-export const htmlTemplate = html`
-  <gr-lib-loader id="libLoader"></gr-lib-loader>
-`;
+export interface ChangeMetadataPluginApi {
+  onLabelsChanged(callback: (value: unknown) => void): ChangeMetadataPluginApi;
+}
diff --git a/polygerrit-ui/app/api/change-reply.ts b/polygerrit-ui/app/api/change-reply.ts
new file mode 100644
index 0000000..37c96ee
--- /dev/null
+++ b/polygerrit-ui/app/api/change-reply.ts
@@ -0,0 +1,39 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export interface LabelsChangedDetail {
+  name: string;
+  value: string;
+}
+export interface ValueChangedDetail {
+  value: string;
+}
+export type ReplyChangedCallback = (text: string) => void;
+export type LabelsChangedCallback = (detail: LabelsChangedDetail) => void;
+
+export interface ChangeReplyPluginApi {
+  getLabelValue(label: string): string;
+
+  setLabelValue(label: string, value: string): void;
+
+  send(includeComments?: boolean): void;
+
+  addReplyTextChangedCallback(handler: ReplyChangedCallback): void;
+
+  addLabelValuesChangedCallback(handler: LabelsChangedCallback): void;
+
+  showMessage(message: string): void;
+}
diff --git a/polygerrit-ui/app/api/checks.ts b/polygerrit-ui/app/api/checks.ts
new file mode 100644
index 0000000..4a5ef7e
--- /dev/null
+++ b/polygerrit-ui/app/api/checks.ts
@@ -0,0 +1,391 @@
+/**
+ * @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 ChecksPluginApi {
+  /**
+   * 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 ChangeData {
+  changeNumber: number;
+  patchsetNumber: number;
+  repo: string;
+}
+
+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: ChangeData): 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;
+
+  /**
+   * Top-level actions that are not associated with a specific run or result.
+   * Will be shown as buttons in the header of the Checks tab.
+   */
+  actions?: Action[];
+  runs?: CheckRun[];
+}
+
+export enum ResponseCode {
+  OK = 'OK',
+  ERROR = 'ERROR',
+  NOT_LOGGED_IN = '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,
+  /**
+   * Identical to 'attempt' property of CheckRun. Not set for top-level
+   * actions.
+   */
+  attempt: number | undefined,
+  /**
+   * Identical to 'externalId' property of CheckRun. Not set for top-level
+   * actions.
+   */
+  externalId: string | undefined,
+  /**
+   * Identical to 'checkName' property of CheckRun. Not set for top-level
+   * actions.
+   */
+  checkName: string | undefined,
+  /** Identical to 'name' property of Action entity. */
+  actionName: string
+) => Promise<ActionResult> | undefined;
+
+export interface ActionResult {
+  /** An empty errorMessage means success. */
+  errorMessage?: string;
+}
+
+export enum RunStatus {
+  RUNNABLE = 'RUNNABLE',
+  RUNNING = 'RUNNING',
+  COMPLETED = '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 = 'INFO',
+  WARNING = 'WARNING',
+  ERROR = '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/api/core.ts b/polygerrit-ui/app/api/core.ts
new file mode 100644
index 0000000..5820139
--- /dev/null
+++ b/polygerrit-ui/app/api/core.ts
@@ -0,0 +1,47 @@
+/**
+ * @fileoverview Core API types for Gerrit.
+ *
+ * Core types are types used in many places in Gerrit, such as the Side enum.
+ *
+ * @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 CommentRange entity describes the range of an inline comment.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-range
+ *
+ * The range includes all characters from the start position, specified by
+ * start_line and start_character, to the end position, specified by end_line
+ * and end_character. The start position is inclusive and the end position is
+ * exclusive.
+ *
+ * So, a range over part of a line will have start_line equal to end_line;
+ * however a range with end_line set to 5 and end_character equal to 0 will not
+ * include any characters on line 5.
+ */
+export declare interface CommentRange {
+  /** The start line number of the range. (1-based) */
+  start_line: number;
+
+  /** The character position in the start line. (0-based) */
+  start_character: number;
+
+  /** The end line number of the range. (1-based) */
+  end_line: number;
+
+  /** The character position in the end line. (0-based) */
+  end_character: number;
+}
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
new file mode 100644
index 0000000..bd110c8
--- /dev/null
+++ b/polygerrit-ui/app/api/diff.ts
@@ -0,0 +1,288 @@
+/**
+ * @fileoverview The API of Gerrit's diff viewer, gr-diff.
+ *
+ * This includes some types which are also defined as part of Gerrit's JSON API
+ * which are used as inputs to gr-diff.
+ *
+ * @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 {CommentRange} from './core';
+
+/**
+ * Diff type in preferences
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-input
+ */
+export enum DiffViewMode {
+  SIDE_BY_SIDE = 'SIDE_BY_SIDE',
+  UNIFIED = 'UNIFIED_DIFF',
+}
+
+/**
+ * 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';
+  /** The content differences in the file as a list of DiffContent entities. */
+  content: DiffContent[];
+  /** 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;
+  // 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;
+
+  /**
+   * Provides info about a move operation the chunk.
+   * It's presence indicates the current chunk exists due to a move.
+   */
+  move_details?: MoveDetails;
+  /**
+   * 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;
+}
+
+/**
+ * Details about move operation related to a specific chunk.
+ */
+export declare interface MoveDetails {
+  /** 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;
+  };
+}
+
+/**
+ * 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 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;
+  ignore_whitespace: IgnoreWhitespaceType;
+  line_length: number;
+  show_line_endings?: boolean;
+  show_tabs?: boolean;
+  show_whitespace_errors?: boolean;
+  syntax_highlighting?: boolean;
+  tab_size: number;
+  font_size: number;
+  // TODO: Missing documentation
+  show_file_comment_button?: boolean;
+}
+
+export declare interface RenderPreferences {
+  hide_left_side?: boolean;
+  disable_context_control_buttons?: boolean;
+}
+
+/**
+ * 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';
+
+export enum Side {
+  LEFT = 'left',
+  RIGHT = 'right',
+}
+
+export enum CoverageType {
+  /**
+   * start_character and end_character of the range will be ignored for this
+   * type.
+   */
+  COVERED = 'COVERED',
+  /**
+   * start_character and end_character of the range will be ignored for this
+   * type.
+   */
+  NOT_COVERED = 'NOT_COVERED',
+  PARTIALLY_COVERED = 'PARTIALLY_COVERED',
+  /**
+   * You don't have to use this. If there is no coverage information for a
+   * range, then it implicitly means NOT_INSTRUMENTED. start_character and
+   * end_character of the range will be ignored for this type.
+   */
+  NOT_INSTRUMENTED = 'NOT_INSTRUMENTED',
+}
+
+export declare interface LineRange {
+  start_line: number;
+  end_line: number;
+}
+
+export declare interface CoverageRange {
+  type: CoverageType;
+  side: Side;
+  code_range: LineRange;
+}
+
+/** LOST LineNumber is for ported comments without a range, they have their own
+ *  line number and are added on top of the FILE row in gr-diff
+ */
+export declare type LineNumber = number | 'FILE' | 'LOST';
+
+/** The detail of the 'create-comment' event dispatched by gr-diff. */
+export declare interface CreateCommentEventDetail {
+  side: Side;
+  lineNum: LineNumber;
+  range: CommentRange | undefined;
+}
+
+export declare interface ContentLoadNeededEventDetail {
+  lineRange: {
+    left: LineRange;
+    right: LineRange;
+  };
+}
+
+export declare interface MovedLinkClickedEventDetail {
+  side: Side;
+  lineNum: LineNumber;
+}
+
+export enum GrDiffLineType {
+  ADD = 'add',
+  BOTH = 'both',
+  BLANK = 'blank',
+  REMOVE = 'remove',
+}
+
+/** Describes a line to be rendered in a diff. */
+export declare interface GrDiffLine {
+  readonly type: GrDiffLineType;
+  /** The line number on the left side of the diff - 0 means none.  */
+  beforeNumber: LineNumber;
+  /** The line number on the right side of the diff - 0 means none.  */
+  afterNumber: LineNumber;
+}
+
+/**
+ * Interface to implemented to define a new layer in the diff.
+ *
+ * Layers can affect how the text of the diff or its line numbers
+ * are rendered.
+ */
+export declare interface DiffLayer {
+  /**
+   * Called during rendering and allows annotating the diff text or line number
+   * by mutating those elements.
+   *
+   * @param textElement The rendered text of one side of the diff.
+   * @param lineNumberElement The rendered line number of one side of the diff.
+   * @param line Describes the line that should be annotated.
+   */
+  annotate(
+    textElement: HTMLElement,
+    lineNumberElement: HTMLElement,
+    line: GrDiffLine
+  ): void;
+}
diff --git a/polygerrit-ui/app/api/event-helper.ts b/polygerrit-ui/app/api/event-helper.ts
new file mode 100644
index 0000000..c4a559b
--- /dev/null
+++ b/polygerrit-ui/app/api/event-helper.ts
@@ -0,0 +1,49 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export type UnsubscribeCallback = () => void;
+
+export interface EventHelperPluginApi {
+  /**
+   * Add a callback to arbitrary event.
+   * The callback may return false to prevent event bubbling.
+   */
+  on(event: string, callback: (event: Event) => boolean): UnsubscribeCallback;
+
+  /**
+   * Alias for @see onClick
+   */
+  onTap(callback: (event: Event) => boolean): UnsubscribeCallback;
+
+  /**
+   * Add a callback to element click or touch.
+   * The callback may return false to prevent event bubbling.
+   */
+  onClick(callback: (event: Event) => boolean): UnsubscribeCallback;
+
+  /**
+   * Alias for @see captureClick
+   */
+  captureTap(callback: (event: Event) => boolean): UnsubscribeCallback;
+
+  /**
+   * Add a callback to element click or touch ahead of normal flow.
+   * Callback is installed on parent during capture phase.
+   * https://www.w3.org/TR/DOM-Level-3-Events/#event-flow
+   * The callback may return false to cancel regular event listeners.
+   */
+  captureClick(callback: (event: Event) => boolean): UnsubscribeCallback;
+}
diff --git a/polygerrit-ui/app/api/hook.ts b/polygerrit-ui/app/api/hook.ts
new file mode 100644
index 0000000..179b967
--- /dev/null
+++ b/polygerrit-ui/app/api/hook.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.
+ */
+interface GerritElementExtensions {
+  content?: HTMLElement & {hidden?: boolean};
+  change?: unknown;
+  revision?: unknown;
+  token?: string;
+  repoName?: string;
+  /**
+   * This is a ConfigInfo object as defined here:
+   * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-info
+   * We neither want to repeat it nor add a dependency on it here.
+   */
+  config?: unknown;
+}
+
+export type HookCallback = (el: HTMLElement & GerritElementExtensions) => void;
+
+export interface RegisterOptions {
+  slot?: string;
+  replace: unknown;
+}
+
+export interface HookApi {
+  onAttached(callback: HookCallback): HookApi;
+
+  onDetached(callback: HookCallback): HookApi;
+
+  getAllAttached(): HTMLElement[];
+
+  getLastAttached(): Promise<HTMLElement>;
+
+  getModuleName(): string;
+
+  handleInstanceDetached(instance: HTMLElement): void;
+
+  handleInstanceAttached(instance: HTMLElement): void;
+}
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts
new file mode 100644
index 0000000..cd742a2
--- /dev/null
+++ b/polygerrit-ui/app/api/plugin.ts
@@ -0,0 +1,92 @@
+/**
+ * @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 {AdminPluginApi} from './admin';
+import {AnnotationPluginApi} from './annotation';
+import {AttributeHelperPluginApi} from './attribute-helper';
+import {ChangeMetadataPluginApi} from './change-metadata';
+import {ChangeReplyPluginApi} from './change-reply';
+import {ChecksPluginApi} from './checks';
+import {EventHelperPluginApi} from './event-helper';
+import {PopupPluginApi} from './popup';
+import {RepoPluginApi} from './repo';
+import {ReportingPluginApi} from './reporting';
+import {SettingsPluginApi} from './settings';
+import {StylesPluginApi} from './styles';
+import {ThemePluginApi} from './theme';
+import {ChangeActionsPluginApi} from './change-actions';
+import {RestPluginApi} from './rest';
+import {HookApi, RegisterOptions} from './hook';
+
+export enum TargetElement {
+  CHANGE_ACTIONS = 'changeactions',
+  REPLY_DIALOG = 'replydialog',
+}
+
+// Note: for new events, naming convention should be: `a-b`
+export enum EventType {
+  HISTORY = 'history',
+  LABEL_CHANGE = 'labelchange',
+  SHOW_CHANGE = 'showchange',
+  SUBMIT_CHANGE = 'submitchange',
+  SHOW_REVISION_ACTIONS = 'show-revision-actions',
+  COMMIT_MSG_EDIT = 'commitmsgedit',
+  COMMENT = 'comment',
+  REVERT = 'revert',
+  REVERT_SUBMISSION = 'revert_submission',
+  POST_REVERT = 'postrevert',
+  ANNOTATE_DIFF = 'annotatediff',
+  ADMIN_MENU_LINKS = 'admin-menu-links',
+  HIGHLIGHTJS_LOADED = 'highlightjs-loaded',
+}
+
+export interface PluginApi {
+  _url?: URL;
+  admin(): AdminPluginApi;
+  annotationApi(): AnnotationPluginApi;
+  attributeHelper(element: Element): AttributeHelperPluginApi;
+  changeActions(): ChangeActionsPluginApi;
+  changeMetadata(): ChangeMetadataPluginApi;
+  changeReply(): ChangeReplyPluginApi;
+  checks(): ChecksPluginApi;
+  eventHelper(element: Node): EventHelperPluginApi;
+  getPluginName(): string;
+  hook(endpointName: string, opt_options?: RegisterOptions): HookApi;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  on(eventName: EventType, target: any): void;
+  popup(): Promise<PopupPluginApi>;
+  popup(moduleName: string): Promise<PopupPluginApi>;
+  popup(moduleName?: string): Promise<PopupPluginApi | null>;
+  project(): RepoPluginApi;
+  registerCustomComponent(
+    endpointName: string,
+    moduleName?: string,
+    options?: RegisterOptions
+  ): HookApi;
+  registerDynamicCustomComponent(
+    endpointName: string,
+    moduleName?: string,
+    options?: RegisterOptions
+  ): HookApi;
+  registerStyleModule(endpoint: string, moduleName: string): void;
+  reporting(): ReportingPluginApi;
+  restApi(): RestPluginApi;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  screen(screenName: string, moduleName?: string): any;
+  settings(): SettingsPluginApi;
+  styles(): StylesPluginApi;
+  theme(): ThemePluginApi;
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts b/polygerrit-ui/app/api/popup.ts
similarity index 65%
copy from polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
copy to polygerrit-ui/app/api/popup.ts
index ac59f4f..60772cc 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
+++ b/polygerrit-ui/app/api/popup.ts
@@ -14,8 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
 
-export const htmlTemplate = html`
-  <gr-lib-loader id="libLoader"></gr-lib-loader>
-`;
+export interface PopupPluginApi {
+  /**
+   * Opens the popup, inserts it into DOM over current UI.
+   * Creates the popup if not previously created. Creates popup content element,
+   * if it was provided with constructor.
+   */
+  open(): Promise<PopupPluginApi>;
+
+  /**
+   * Hides the popup.
+   */
+  close(): void;
+}
diff --git a/polygerrit-ui/app/api/repo.ts b/polygerrit-ui/app/api/repo.ts
new file mode 100644
index 0000000..a626471
--- /dev/null
+++ b/polygerrit-ui/app/api/repo.ts
@@ -0,0 +1,31 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export type RepoCommandCallback = (
+  repo?: string,
+  /**
+   * This is a ConfigInfo object as defined here:
+   * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-info
+   * We neither want to repeat it nor add a dependency on it here.
+   */
+  config?: unknown
+) => boolean;
+
+export interface RepoPluginApi {
+  createCommand(title: string, callback: RepoCommandCallback): RepoPluginApi;
+
+  onTap(callback: (event: Event) => boolean): RepoPluginApi;
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts b/polygerrit-ui/app/api/reporting.ts
similarity index 69%
copy from polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
copy to polygerrit-ui/app/api/reporting.ts
index ac59f4f..65bdc3f 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
+++ b/polygerrit-ui/app/api/reporting.ts
@@ -14,8 +14,12 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
 
-export const htmlTemplate = html`
-  <gr-lib-loader id="libLoader"></gr-lib-loader>
-`;
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type EventDetails = any;
+
+export interface ReportingPluginApi {
+  reportInteraction(eventName: string, details?: EventDetails): void;
+
+  reportLifeCycle(eventName: string, details?: EventDetails): void;
+}
diff --git a/polygerrit-ui/app/api/rest.ts b/polygerrit-ui/app/api/rest.ts
new file mode 100644
index 0000000..fd9cada
--- /dev/null
+++ b/polygerrit-ui/app/api/rest.ts
@@ -0,0 +1,106 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export type RequestPayload = string | object;
+
+export enum HttpMethod {
+  HEAD = 'HEAD',
+  POST = 'POST',
+  GET = 'GET',
+  DELETE = 'DELETE',
+  PUT = 'PUT',
+}
+
+export type ErrorCallback = (response?: Response | null, err?: Error) => void;
+
+export interface RestPluginApi {
+  getLoggedIn(): Promise<boolean>;
+
+  getVersion(): Promise<string | undefined>;
+
+  /**
+   * Returns a ServerInfo object as defined here:
+   * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#server-info
+   * We neither want to repeat it nor add a dependency on it here.
+   */
+  getConfig(): Promise<unknown>;
+
+  invalidateReposCache(): void;
+
+  fetch(
+    method: HttpMethod,
+    url: string,
+    payload?: RequestPayload,
+    errFn?: undefined,
+    contentType?: string
+  ): Promise<Response>;
+
+  fetch(
+    method: HttpMethod,
+    url: string,
+    payload: RequestPayload | undefined,
+    errFn: ErrorCallback,
+    contentType?: string
+  ): Promise<Response | void>;
+
+  fetch(
+    method: HttpMethod,
+    url: string,
+    payload: RequestPayload | undefined,
+    errFn?: ErrorCallback,
+    contentType?: string
+  ): Promise<Response | void>;
+
+  /**
+   * Fetch and return native browser REST API Response.
+   */
+  fetch(
+    method: HttpMethod,
+    url: string,
+    payload?: RequestPayload,
+    errFn?: ErrorCallback,
+    contentType?: string
+  ): Promise<Response | void>;
+
+  /**
+   * Fetch and parse REST API response, if request succeeds.
+   */
+  send(
+    method: HttpMethod,
+    url: string,
+    payload?: RequestPayload,
+    errFn?: ErrorCallback,
+    contentType?: string
+  ): Promise<unknown>;
+
+  get(url: string): Promise<unknown>;
+
+  post(
+    url: string,
+    payload?: RequestPayload,
+    errFn?: ErrorCallback,
+    contentType?: string
+  ): Promise<unknown>;
+
+  put(
+    url: string,
+    payload?: RequestPayload,
+    errFn?: ErrorCallback,
+    contentType?: string
+  ): Promise<unknown>;
+
+  delete(url: string): Promise<Response>;
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts b/polygerrit-ui/app/api/settings.ts
similarity index 71%
copy from polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
copy to polygerrit-ui/app/api/settings.ts
index ac59f4f..03cf474 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
+++ b/polygerrit-ui/app/api/settings.ts
@@ -14,8 +14,14 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {HookApi} from './hook';
 
-export const htmlTemplate = html`
-  <gr-lib-loader id="libLoader"></gr-lib-loader>
-`;
+export interface SettingsPluginApi {
+  title(newTitle: string): SettingsPluginApi;
+
+  token(newToken: string): SettingsPluginApi;
+
+  module(newModuleName: string): SettingsPluginApi;
+
+  build(): HookApi;
+}
diff --git a/polygerrit-ui/app/api/styles.ts b/polygerrit-ui/app/api/styles.ts
new file mode 100644
index 0000000..233c3e2
--- /dev/null
+++ b/polygerrit-ui/app/api/styles.ts
@@ -0,0 +1,35 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export interface StyleObject {
+  /**
+   * Creates a new unique CSS class and injects it in a root node of the element
+   * 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): string;
+
+  /**
+   * Apply shared style to the element.
+   */
+  apply(element: Element): void;
+}
+
+export interface StylesPluginApi {
+  css(ruleStr: string): StyleObject;
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts b/polygerrit-ui/app/api/theme.ts
similarity index 80%
rename from polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
rename to polygerrit-ui/app/api/theme.ts
index ac59f4f..70ffcb3 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
+++ b/polygerrit-ui/app/api/theme.ts
@@ -14,8 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
 
-export const htmlTemplate = html`
-  <gr-lib-loader id="libLoader"></gr-lib-loader>
-`;
+export interface ThemePluginApi {
+  setHeaderLogoAndTitle(logoUrl: string, title: string): void;
+}
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index b64a7d1..be502f7 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -18,13 +18,18 @@
 /**
  * @desc Tab names for primary tabs on change view page.
  */
+import {DiffViewMode} from '../api/diff';
+import {DiffPreferencesInfo} from '../types/diff';
+import {EditPreferencesInfo, PreferencesInfo} from '../types/common';
+
 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',
 }
 
 /**
@@ -48,6 +53,7 @@
   TAG_SET_WIP = 'autogenerated:gerrit:setWorkInProgress',
   TAG_SET_ASSIGNEE = 'autogenerated:gerrit:setAssignee',
   TAG_UNSET_ASSIGNEE = 'autogenerated:gerrit:deleteAssignee',
+  TAG_MERGED = 'autogenerated:gerrit:merged',
 }
 
 /**
@@ -156,10 +162,7 @@
   HIDDEN = 'HIDDEN',
 }
 
-export enum Side {
-  LEFT = 'left',
-  RIGHT = 'right',
-}
+export {Side} from '../api/diff';
 
 /**
  * The type in ConfigParameterInfo entity.
@@ -287,14 +290,7 @@
   HHMM_24 = 'HHMM_24',
 }
 
-/**
- * Diff type in preferences
- * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-input
- */
-export enum DiffViewMode {
-  SIDE_BY_SIDE = 'SIDE_BY_SIDE',
-  UNIFIED = 'UNIFIED_DIFF',
-}
+export {DiffViewMode};
 
 /**
  * The type of email strategy to use.
@@ -327,17 +323,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 {
@@ -400,3 +385,57 @@
   REF_UPDATED_AND_CHANGE_REINDEX = 'REF_UPDATED_AND_CHANGE_REINDEX',
   NEVER = 'NEVER',
 }
+
+// TODO(TS): Many properties are omitted here, but they are required.
+// Add default values for missing properties.
+export function createDefaultPreferences() {
+  return {
+    changes_per_page: 25,
+    default_diff_view: DiffViewMode.SIDE_BY_SIDE,
+    diff_view: DiffViewMode.SIDE_BY_SIDE,
+    size_bar_in_change_table: true,
+  } as PreferencesInfo;
+}
+
+// These defaults should match the defaults in
+// java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
+// NOTE: There are some settings that don't apply to PolyGerrit
+// (Render mode being at least one of them).
+export function createDefaultDiffPrefs(): DiffPreferencesInfo {
+  return {
+    context: 10,
+    cursor_blink_rate: 0,
+    font_size: 12,
+    ignore_whitespace: 'IGNORE_NONE',
+    line_length: 100,
+    line_wrapping: false,
+    show_line_endings: true,
+    show_tabs: true,
+    show_whitespace_errors: true,
+    syntax_highlighting: true,
+    tab_size: 8,
+  };
+}
+
+// These defaults should match the defaults in
+// java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
+export function createDefaultEditPrefs(): EditPreferencesInfo {
+  return {
+    auto_close_brackets: false,
+    cursor_blink_rate: 0,
+    hide_line_numbers: false,
+    hide_top_menu: false,
+    indent_unit: 2,
+    indent_with_tabs: false,
+    key_map_type: 'DEFAULT',
+    line_length: 100,
+    line_wrapping: false,
+    match_brackets: true,
+    show_base: false,
+    show_tabs: true,
+    show_whitespace_errors: true,
+    syntax_highlighting: true,
+    tab_size: 8,
+    theme: 'DEFAULT',
+  };
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
index 3c155f85..cbb3d95 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
@@ -19,7 +19,6 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-icons/gr-icons';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-permission/gr-permission';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
@@ -43,6 +42,7 @@
   LabelNameToLabelTypeInfoMap,
 } from '../../../types/common';
 import {PolymerDomRepeatEvent} from '../../../types/types';
+import {fireEvent} from '../../../utils/event-util';
 
 /**
  * Fired when the section has been modified or removed.
@@ -141,9 +141,7 @@
       // For a new section, this is not fired because new permissions and
       // rules have to be added in order to save, modifying the ref is not
       // enough.
-      this.dispatchEvent(
-        new CustomEvent('access-modified', {bubbles: true, composed: true})
-      );
+      fireEvent(this, 'access-modified');
     }
     this.section.value.updatedId = this.section.id;
   }
@@ -276,18 +274,11 @@
       return;
     }
     if (this.section.value.added) {
-      this.dispatchEvent(
-        new CustomEvent('added-section-removed', {
-          bubbles: true,
-          composed: true,
-        })
-      );
+      fireEvent(this, 'added-section-removed');
     }
     this._deleted = true;
     this.section.value.deleted = true;
-    this.dispatchEvent(
-      new CustomEvent('access-modified', {bubbles: true, composed: true})
-    );
+    fireEvent(this, 'access-modified');
   }
 
   _handleUndoRemove() {
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.ts
index 7c9f28b..46968eb 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.ts
@@ -153,5 +153,4 @@
     </div>
     <!-- end deletedContainer -->
   </fieldset>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
index 9d40e28..4fa84eb 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
@@ -20,7 +20,6 @@
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-list-view/gr-list-view';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-create-group-dialog/gr-create-group-dialog';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
@@ -32,8 +31,9 @@
 import {AppElementAdminParams} from '../../gr-app-types';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GroupId, GroupInfo, GroupName} from '../../../types/common';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GrCreateGroupDialog} from '../gr-create-group-dialog/gr-create-group-dialog';
+import {fireTitleChange} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -45,7 +45,6 @@
   $: {
     createOverlay: GrOverlay;
     createNewModal: GrCreateGroupDialog;
-    restAPI: RestApiService & Element;
   };
 }
 
@@ -96,17 +95,13 @@
   @property({type: String})
   _filter = '';
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
   attached() {
     super.attached();
     this._getCreateGroupCapability();
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'Groups'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, 'Groups');
     this._maybeOpenCreateOverlay(this.params);
   }
 
@@ -136,11 +131,11 @@
   }
 
   _getCreateGroupCapability() {
-    return this.$.restAPI.getAccount().then(account => {
+    return this.restApiService.getAccount().then(account => {
       if (!account) {
         return;
       }
-      return this.$.restAPI
+      return this.restApiService
         .getAccountCapabilities(['createGroup'])
         .then(capabilities => {
           if (capabilities?.createGroup) {
@@ -152,7 +147,7 @@
 
   _getGroups(filter: string, groupsPerPage: number, offset?: number) {
     this._groups = [];
-    return this.$.restAPI
+    return this.restApiService
       .getGroups(filter, groupsPerPage, offset)
       .then(groups => {
         if (!groups) {
@@ -168,7 +163,7 @@
   }
 
   _refreshGroupsList() {
-    this.$.restAPI.invalidateGroupsCache();
+    this.restApiService.invalidateGroupsCache();
     return this._getGroups(this._filter, this._groupsPerPage, this._offset);
   }
 
@@ -183,7 +178,9 @@
   }
 
   _handleCreateClicked() {
-    this.$.createOverlay.open();
+    this.$.createOverlay.open().then(() => {
+      this.$.createNewModal.focus();
+    });
   }
 
   _visibleToAll(item: GroupInfo) {
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.ts
index 93de8b4..7574f79 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.ts
@@ -78,5 +78,4 @@
       </div>
     </gr-dialog>
   </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js
index 93d41c3..2df1ac6 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js
@@ -19,6 +19,7 @@
 import './gr-admin-group-list.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import 'lodash/lodash.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-admin-group-list');
 
@@ -73,24 +74,16 @@
   });
 
   suite('list with groups', () => {
-    setup(done => {
+    setup(async () => {
       groups = _.times(26, groupGenerator);
-
-      stub('gr-rest-api-interface', {
-        getGroups(num, offset) {
-          return Promise.resolve(groups);
-        },
-      });
-
-      element._paramsChanged(value).then(() => { flush(done); });
+      stubRestApi('getGroups').returns(Promise.resolve(groups));
+      element._paramsChanged(value);
+      await flush();
     });
 
-    test('test for test group in the list', done => {
-      flush(() => {
-        assert.equal(element._groups[1].name, '1');
-        assert.equal(element._groups[1].options.visible_to_all, false);
-        done();
-      });
+    test('test for test group in the list', () => {
+      assert.equal(element._groups[1].name, '1');
+      assert.equal(element._groups[1].options.visible_to_all, false);
     });
 
     test('_shownGroups', () => {
@@ -113,13 +106,7 @@
   suite('test with less then 25 groups', () => {
     setup(done => {
       groups = _.times(25, groupGenerator);
-
-      stub('gr-rest-api-interface', {
-        getGroups(num, offset) {
-          return Promise.resolve(groups);
-        },
-      });
-
+      stubRestApi('getGroups').returns(Promise.resolve(groups));
       element._paramsChanged(value).then(() => { flush(done); });
     });
 
@@ -129,20 +116,15 @@
   });
 
   suite('filter', () => {
-    test('_paramsChanged', done => {
-      sinon.stub(
-          element.$.restAPI,
-          'getGroups')
-          .callsFake(() => Promise.resolve(groups));
+    test('_paramsChanged', async () => {
+      const getGroupsStub = stubRestApi('getGroups');
+      getGroupsStub.returns(Promise.resolve(groups));
       const value = {
         filter: 'test',
         offset: 25,
       };
-      element._paramsChanged(value).then(() => {
-        assert.isTrue(element.$.restAPI.getGroups.lastCall
-            .calledWithExactly('test', 25, 25));
-        done();
-      });
+      await element._paramsChanged(value);
+      assert.isTrue(getGroupsStub.lastCall.calledWithExactly('test', 25, 25));
     });
   });
 
@@ -173,7 +155,8 @@
     });
 
     test('_handleCreateClicked opens modal', () => {
-      const openStub = sinon.stub(element.$.createOverlay, 'open');
+      const openStub = sinon.stub(element.$.createOverlay, 'open').returns(
+          Promise.resolve());
       element._handleCreateClicked();
       assert.isTrue(openStub.called);
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
index 7fb713f..f2b4c89 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
@@ -19,9 +19,7 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-dropdown-list/gr-dropdown-list';
 import '../../shared/gr-icons/gr-icons';
-import '../../shared/gr-js-api-interface/gr-js-api-interface';
 import '../../shared/gr-page-nav/gr-page-nav';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-admin-group-list/gr-admin-group-list';
 import '../gr-group/gr-group';
 import '../gr-group-audit-log/gr-group-audit-log';
@@ -40,7 +38,6 @@
 import {getBaseUrl} from '../../../utils/url-util';
 import {
   GerritNav,
-  GerritView,
   GroupDetailView,
   RepoDetailView,
 } from '../../core/gr-navigation/gr-navigation';
@@ -52,7 +49,6 @@
   SubsectionInterface,
 } from '../../../utils/admin-nav-util';
 import {customElement, observe, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {
   AppElementAdminParams,
   AppElementGroupParams,
@@ -66,17 +62,11 @@
 } from '../../../types/common';
 import {GroupNameChangedDetail} from '../gr-group/gr-group';
 import {ValueChangeDetail} from '../../shared/gr-dropdown-list/gr-dropdown-list';
-import {GrJsApiInterface} from '../../shared/gr-js-api-interface/gr-js-api-interface-element';
+import {appContext} from '../../../services/app-context';
+import {GerritView} from '../../../services/router/router-model';
 
 const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
 
-export interface GrAdminView {
-  $: {
-    restAPI: RestApiService & Element;
-    jsAPI: GrJsApiInterface;
-  };
-}
-
 interface AdminSubsectionLink {
   text: string;
   value: string;
@@ -109,7 +99,7 @@
     return htmlTemplate;
   }
 
-  private _account?: AccountDetailInfo;
+  private account?: AccountDetailInfo;
 
   @property({type: Object})
   params?: AdminViewParams;
@@ -183,6 +173,10 @@
   @property({type: Boolean})
   _showPluginList?: boolean;
 
+  private readonly restApiService = appContext.restApiService;
+
+  private readonly jsAPI = appContext.jsApiService;
+
   /** @override */
   attached() {
     super.attached();
@@ -191,11 +185,11 @@
 
   reload() {
     const promises: [Promise<AccountDetailInfo | undefined>, Promise<void>] = [
-      this.$.restAPI.getAccount(),
+      this.restApiService.getAccount(),
       getPluginLoader().awaitPluginsLoaded(),
     ];
     return Promise.all(promises).then(result => {
-      this._account = result[0];
+      this.account = result[0];
       let options: AdminNavLinksOption | undefined = undefined;
       if (this._repoName) {
         options = {repoName: this._repoName};
@@ -210,15 +204,15 @@
       }
 
       return getAdminLinks(
-        this._account,
+        this.account,
         () =>
-          this.$.restAPI.getAccountCapabilities().then(capabilities => {
+          this.restApiService.getAccountCapabilities().then(capabilities => {
             if (!capabilities) {
               throw new Error('getAccountCapabilities returns undefined');
             }
             return capabilities;
           }),
-        () => this.$.jsAPI.getAdminMenuLinks(),
+        () => this.jsAPI.getAdminMenuLinks(),
         options
       ).then(res => {
         this._filteredLinks = res.links;
@@ -406,7 +400,7 @@
       }
       return '';
     }
-    // TODO(TS): The following condtion seems always false, because params
+    // TODO(TS): The following condition seems always false, because params
     // never has detailType property. Remove it.
     if (
       ((params as unknown) as AdminSubsectionLink).detailType &&
@@ -423,7 +417,7 @@
     if (!groupId) return;
 
     const promises: Array<Promise<void>> = [];
-    this.$.restAPI.getGroupConfig(groupId).then(group => {
+    this.restApiService.getGroupConfig(groupId).then(group => {
       if (!group || !group.name) {
         return;
       }
@@ -433,13 +427,13 @@
       this.reload();
 
       promises.push(
-        this.$.restAPI.getIsAdmin().then(isAdmin => {
+        this.restApiService.getIsAdmin().then(isAdmin => {
           this._isAdmin = !!isAdmin;
         })
       );
 
       promises.push(
-        this.$.restAPI.getIsGroupOwner(group.name).then(isOwner => {
+        this.restApiService.getIsGroupOwner(group.name).then(isOwner => {
           this._groupOwner = isOwner;
         })
       );
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_html.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_html.ts
index 5e85a93..f073a9f 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_html.ts
@@ -51,7 +51,7 @@
     .selectText.show {
       display: inline-block;
     }
-    main.breadcrumbs:not(.table) {
+    .main.breadcrumbs:not(.table) {
       margin-top: var(--spacing-l);
     }
   </style>
@@ -114,70 +114,68 @@
     </section>
   </template>
   <template is="dom-if" if="[[_showRepoList]]" restamp="true">
-    <main class="table">
+    <div class="main table">
       <gr-repo-list class="table" params="[[params]]"></gr-repo-list>
-    </main>
+    </div>
   </template>
   <template is="dom-if" if="[[_showGroupList]]" restamp="true">
-    <main class="table">
+    <div class="main table">
       <gr-admin-group-list class="table" params="[[params]]">
       </gr-admin-group-list>
-    </main>
+    </div>
   </template>
   <template is="dom-if" if="[[_showPluginList]]" restamp="true">
-    <main class="table">
+    <div class="main table">
       <gr-plugin-list class="table" params="[[params]]"></gr-plugin-list>
-    </main>
+    </div>
   </template>
   <template is="dom-if" if="[[_showRepoMain]]" restamp="true">
-    <main class="breadcrumbs">
+    <div class="main breadcrumbs">
       <gr-repo repo="[[params.repo]]"></gr-repo>
-    </main>
+    </div>
   </template>
   <template is="dom-if" if="[[_showGroup]]" restamp="true">
-    <main class="breadcrumbs">
+    <div class="main breadcrumbs">
       <gr-group
         group-id="[[params.groupId]]"
         on-name-changed="_updateGroupName"
       ></gr-group>
-    </main>
+    </div>
   </template>
   <template is="dom-if" if="[[_showGroupMembers]]" restamp="true">
-    <main class="breadcrumbs">
+    <div class="main breadcrumbs">
       <gr-group-members group-id="[[params.groupId]]"></gr-group-members>
-    </main>
+    </div>
   </template>
   <template is="dom-if" if="[[_showRepoDetailList]]" restamp="true">
-    <main class="table breadcrumbs">
+    <div class="main table breadcrumbs">
       <gr-repo-detail-list
         params="[[params]]"
         class="table"
       ></gr-repo-detail-list>
-    </main>
+    </div>
   </template>
   <template is="dom-if" if="[[_showGroupAuditLog]]" restamp="true">
-    <main class="table breadcrumbs">
+    <div class="main table breadcrumbs">
       <gr-group-audit-log
         group-id="[[params.groupId]]"
         class="table"
       ></gr-group-audit-log>
-    </main>
+    </div>
   </template>
   <template is="dom-if" if="[[_showRepoCommands]]" restamp="true">
-    <main class="breadcrumbs">
+    <div class="main breadcrumbs">
       <gr-repo-commands repo="[[params.repo]]"></gr-repo-commands>
-    </main>
+    </div>
   </template>
   <template is="dom-if" if="[[_showRepoAccess]]" restamp="true">
-    <main class="breadcrumbs">
+    <div class="main breadcrumbs">
       <gr-repo-access path="[[path]]" repo="[[params.repo]]"></gr-repo-access>
-    </main>
+    </div>
   </template>
   <template is="dom-if" if="[[_showRepoDashboards]]" restamp="true">
-    <main class="table breadcrumbs">
+    <div class="main table breadcrumbs">
       <gr-repo-dashboards repo="[[params.repo]]"></gr-repo-dashboards>
-    </main>
+    </div>
   </template>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
index 44fd4d6..e813bec 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
@@ -18,22 +18,28 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-admin-view.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
 import {stubBaseUrl} from '../../../test/test-utils.js';
+import {GerritView} from '../../../services/router/router-model.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-admin-view');
 
+function createAdminCapabilities() {
+  return {
+    createGroup: true,
+    createProject: true,
+    viewPlugins: true,
+  };
+}
+
 suite('gr-admin-view tests', () => {
   let element;
 
   setup(done => {
     element = basicFixture.instantiate();
-    stub('gr-rest-api-interface', {
-      getProjectConfig() {
-        return Promise.resolve({});
-      },
-    });
+    stubRestApi('getProjectConfig').returns(Promise.resolve({}));
     const pluginsLoaded = Promise.resolve();
     sinon.stub(getPluginLoader(), 'awaitPluginsLoaded').returns(pluginsLoaded);
     pluginsLoaded.then(() => flush(done));
@@ -83,18 +89,11 @@
   });
 
   test('_filteredLinks admin', done => {
-    sinon.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
+    stubRestApi('getAccount').returns(Promise.resolve({
       name: 'test-user',
     }));
-    sinon.stub(
-        element.$.restAPI,
-        'getAccountCapabilities')
-        .callsFake(() => Promise.resolve({
-          createGroup: true,
-          createProject: true,
-          viewPlugins: true,
-        })
-        );
+    stubRestApi('getAccountCapabilities').returns(
+        Promise.resolve(createAdminCapabilities()));
     element.reload().then(() => {
       assert.equal(element._filteredLinks.length, 3);
 
@@ -110,39 +109,26 @@
     });
   });
 
-  test('_filteredLinks non admin authenticated', done => {
-    sinon.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
-      name: 'test-user',
-    }));
-    sinon.stub(
-        element.$.restAPI,
-        'getAccountCapabilities')
-        .callsFake(() => Promise.resolve({})
-        );
-    element.reload().then(() => {
-      assert.equal(element._filteredLinks.length, 2);
-
-      // Repos
-      assert.isNotOk(element._filteredLinks[0].subsection);
-
-      // Groups
-      assert.isNotOk(element._filteredLinks[0].subsection);
-      done();
-    });
+  test('_filteredLinks non admin authenticated', async () => {
+    await element.reload();
+    assert.equal(element._filteredLinks.length, 2);
+    // Repos
+    assert.isNotOk(element._filteredLinks[0].subsection);
+    // Groups
+    assert.isNotOk(element._filteredLinks[0].subsection);
   });
 
-  test('_filteredLinks non admin unathenticated', done => {
-    element.reload().then(() => {
-      assert.equal(element._filteredLinks.length, 1);
-
-      // Repos
-      assert.isNotOk(element._filteredLinks[0].subsection);
-      done();
-    });
+  test('_filteredLinks non admin unathenticated', async () => {
+    stubRestApi('getAccount').returns(Promise.resolve(undefined));
+    await element.reload();
+    assert.equal(element._filteredLinks.length, 1);
+    // Repos
+    assert.isNotOk(element._filteredLinks[0].subsection);
   });
 
   test('_filteredLinks from plugin', () => {
-    sinon.stub(element.$.jsAPI, 'getAdminMenuLinks').returns([
+    stubRestApi('getAccount').returns(Promise.resolve(undefined));
+    sinon.stub(element.jsAPI, 'getAdminMenuLinks').returns([
       {text: 'internal link text', url: '/internal/link/url'},
       {text: 'external link text', url: 'http://external/link/url'},
     ]);
@@ -171,17 +157,11 @@
 
   test('Repo shows up in nav', done => {
     element._repoName = 'Test Repo';
-    sinon.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
+    stubRestApi('getAccount').returns(Promise.resolve({
       name: 'test-user',
     }));
-    sinon.stub(
-        element.$.restAPI,
-        'getAccountCapabilities')
-        .callsFake(() => Promise.resolve({
-          createGroup: true,
-          createProject: true,
-          viewPlugins: true,
-        }));
+    stubRestApi('getAccountCapabilities').returns(
+        Promise.resolve(createAdminCapabilities()));
     element.reload().then(() => {
       flush();
       assert.equal(dom(element.root)
@@ -196,53 +176,31 @@
     });
   });
 
-  test('Group shows up in nav', done => {
+  test('Group shows up in nav', async () => {
     element._groupId = 'a15262';
     element._groupName = 'my-group';
     element._groupIsInternal = true;
     element._isAdmin = true;
     element._groupOwner = false;
-    sinon.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
-      name: 'test-user',
-    }));
-    sinon.stub(
-        element.$.restAPI,
-        'getAccountCapabilities')
-        .callsFake(() => Promise.resolve({
-          createGroup: true,
-          createProject: true,
-          viewPlugins: true,
-        }));
-    element.reload().then(() => {
-      flush();
-      assert.equal(element._filteredLinks.length, 3);
-
-      // Repos
-      assert.isNotOk(element._filteredLinks[0].subsection);
-
-      // Groups
-      assert.equal(element._filteredLinks[1].subsection.children.length, 2);
-      assert.equal(element._filteredLinks[1].subsection.name, 'my-group');
-
-      // Plugins
-      assert.isNotOk(element._filteredLinks[2].subsection);
-      done();
-    });
+    stubRestApi('getAccount').returns(Promise.resolve({name: 'test-user'}));
+    stubRestApi('getAccountCapabilities').returns(
+        Promise.resolve(createAdminCapabilities()));
+    await element.reload();
+    await flush();
+    assert.equal(element._filteredLinks.length, 3);
+    // Repos
+    assert.isNotOk(element._filteredLinks[0].subsection);
+    // Groups
+    assert.equal(element._filteredLinks[1].subsection.children.length, 2);
+    assert.equal(element._filteredLinks[1].subsection.name, 'my-group');
+    // Plugins
+    assert.isNotOk(element._filteredLinks[2].subsection);
   });
 
   test('Nav is reloaded when repo changes', () => {
-    sinon.stub(
-        element.$.restAPI,
-        'getAccountCapabilities')
-        .callsFake(() => Promise.resolve({
-          createGroup: true,
-          createProject: true,
-          viewPlugins: true,
-        }));
-    sinon.stub(
-        element.$.restAPI,
-        'getAccount')
-        .callsFake(() => Promise.resolve({_id: 1}));
+    stubRestApi('getAccountCapabilities').returns(
+        Promise.resolve(createAdminCapabilities()));
+    stubRestApi('getAccount').returns(Promise.resolve({_id: 1}));
     sinon.stub(element, 'reload');
     element.params = {repo: 'Test Repo', view: GerritView.REPO};
     assert.equal(element.reload.callCount, 1);
@@ -253,18 +211,9 @@
 
   test('Nav is reloaded when group changes', () => {
     sinon.stub(element, '_computeGroupName');
-    sinon.stub(
-        element.$.restAPI,
-        'getAccountCapabilities')
-        .callsFake(() => Promise.resolve({
-          createGroup: true,
-          createProject: true,
-          viewPlugins: true,
-        }));
-    sinon.stub(
-        element.$.restAPI,
-        'getAccount')
-        .callsFake(() => Promise.resolve({_id: 1}));
+    stubRestApi('getAccountCapabilities').returns(
+        Promise.resolve(createAdminCapabilities()));
+    stubRestApi('getAccount').returns(Promise.resolve({_id: 1}));
     sinon.stub(element, 'reload');
     element.params = {groupId: '1', view: GerritView.GROUP};
     assert.equal(element.reload.callCount, 1);
@@ -320,18 +269,9 @@
       view: GerritNav.View.REPO,
       detail: GerritNav.RepoDetailView.ACCESS,
     };
-    sinon.stub(
-        element.$.restAPI,
-        'getAccountCapabilities')
-        .callsFake(() => Promise.resolve({
-          createGroup: true,
-          createProject: true,
-          viewPlugins: true,
-        }));
-    sinon.stub(
-        element.$.restAPI,
-        'getAccount')
-        .callsFake(() => Promise.resolve({_id: 1}));
+    stubRestApi('getAccountCapabilities').returns(
+        Promise.resolve(createAdminCapabilities()));
+    stubRestApi('getAccount').returns(Promise.resolve({_id: 1}));
     flush();
     const expectedFilteredLinks = [
       {
@@ -483,19 +423,9 @@
 
   suite('_computeSelectedClass', () => {
     setup(() => {
-      sinon.stub(
-          element.$.restAPI,
-          'getAccountCapabilities')
-          .callsFake(() => Promise.resolve({
-            createGroup: true,
-            createProject: true,
-            viewPlugins: true,
-          }));
-      sinon.stub(
-          element.$.restAPI,
-          'getAccount')
-          .callsFake(() => Promise.resolve({_id: 1}));
-
+      stubRestApi('getAccountCapabilities').returns(
+          Promise.resolve(createAdminCapabilities()));
+      stubRestApi('getAccount').returns(Promise.resolve({_id: 1}));
       return element.reload();
     });
 
@@ -568,6 +498,7 @@
     });
 
     suite('groups', () => {
+      let getGroupConfigStub;
       setup(() => {
         stub('gr-group', {
           _loadGroup: () => Promise.resolve({}),
@@ -576,12 +507,12 @@
           _loadGroupDetails: () => {},
         });
 
-        sinon.stub(element.$.restAPI, 'getGroupConfig')
-            .returns(Promise.resolve({
-              name: 'foo',
-              id: 'c0f83e941ce90caea30e6ad88f0d4ea0e841a7a9',
-            }));
-        sinon.stub(element.$.restAPI, 'getIsGroupOwner')
+        getGroupConfigStub = stubRestApi('getGroupConfig');
+        getGroupConfigStub.returns(Promise.resolve({
+          name: 'foo',
+          id: 'c0f83e941ce90caea30e6ad88f0d4ea0e841a7a9',
+        }));
+        stubRestApi('getIsGroupOwner')
             .returns(Promise.resolve(true));
         return element.reload();
       });
@@ -619,12 +550,10 @@
       });
 
       test('external group', () => {
-        element.$.restAPI.getGroupConfig.restore();
-        sinon.stub(element.$.restAPI, 'getGroupConfig')
-            .returns(Promise.resolve({
-              name: 'foo',
-              id: 'external-id',
-            }));
+        getGroupConfigStub.returns(Promise.resolve({
+          name: 'foo',
+          id: 'external-id',
+        }));
         element.params = {
           view: GerritNav.View.GROUP,
           groupId: 1234,
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 2a6c7a8..2124949 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
@@ -19,7 +19,6 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-select/gr-select';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
@@ -35,17 +34,15 @@
   InheritedBooleanInfo,
 } from '../../../types/common';
 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';
+import {appContext} from '../../../services/app-context';
 
 const SUGGESTIONS_LIMIT = 15;
 const REF_PREFIX = 'refs/heads/';
 
 export interface GrCreateChangeDialog {
   $: {
-    restAPI: RestApiService & Element;
     privateChangeCheckBox: HTMLInputElement;
     branchInput: GrAutocomplete;
     tagNameInput: HTMLInputElement;
@@ -93,6 +90,8 @@
   @property({type: Boolean})
   _privateChangesEnabled?: boolean;
 
+  restApiService = appContext.restApiService;
+
   constructor() {
     super();
     this._query = (input: string) => this._getRepoBranchesSuggestions(input);
@@ -108,14 +107,14 @@
     const promises = [];
 
     promises.push(
-      this.$.restAPI.getProjectConfig(this.repoName).then(config => {
+      this.restApiService.getProjectConfig(this.repoName).then(config => {
         if (!config) return;
         this.privateByDefault = config.private_by_default;
       })
     );
 
     promises.push(
-      this.$.restAPI.getConfig().then(config => {
+      this.restApiService.getConfig().then(config => {
         if (!config) {
           return;
         }
@@ -143,7 +142,7 @@
     }
     const isPrivate = this.$.privateChangeCheckBox.checked;
     const isWip = true;
-    return this.$.restAPI
+    return this.restApiService
       .createChange(
         this.repoName,
         this.branch,
@@ -169,24 +168,17 @@
     if (input.startsWith(REF_PREFIX)) {
       input = input.substring(REF_PREFIX.length);
     }
-    return this.$.restAPI
+    return this.restApiService
       .getRepoBranches(input, this.repoName, SUGGESTIONS_LIMIT)
       .then(response => {
         if (!response) return [];
         const branches = [];
-        let branch;
-        for (const key in response) {
-          if (!hasOwnProperty(response, key)) {
-            continue;
+        for (const branchInfo of response) {
+          let name: string = branchInfo.ref;
+          if (name.startsWith('refs/heads/')) {
+            name = name.substring('refs/heads/'.length);
           }
-          if (response[key].ref.startsWith('refs/heads/')) {
-            branch = response[key].ref.substring('refs/heads/'.length);
-          } else {
-            branch = response[key].ref;
-          }
-          branches.push({
-            name: branch,
-          });
+          branches.push({name});
         }
         return branches;
       });
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_html.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_html.ts
index 77e2c3b..47f3818 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_html.ts
@@ -113,5 +113,4 @@
       </span>
     </section>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts
index e529730..198794e 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts
@@ -21,6 +21,7 @@
 import {BranchName, GitRef, RepoName} from '../../../types/common';
 import {InheritedBooleanInfoConfiguredValue} from '../../../constants/constants';
 import {createChange, createConfig} from '../../../test/test-data-generators';
+import {stubRestApi} from '../../../test/test-utils';
 
 const basicFixture = fixtureFromElement('gr-create-change-dialog');
 
@@ -28,23 +29,18 @@
   let element: GrCreateChangeDialog;
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() {
-        return Promise.resolve(true);
-      },
-      getRepoBranches(input) {
-        if (input.startsWith('test')) {
-          return Promise.resolve([
-            {
-              ref: 'refs/heads/test-branch' as GitRef,
-              revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
-              can_delete: true,
-            },
-          ]);
-        } else {
-          return Promise.resolve([]);
-        }
-      },
+    stubRestApi('getRepoBranches').callsFake((input: string) => {
+      if (input.startsWith('test')) {
+        return Promise.resolve([
+          {
+            ref: 'refs/heads/test-branch' as GitRef,
+            revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
+            can_delete: true,
+          },
+        ]);
+      } else {
+        return Promise.resolve([]);
+      }
     });
     element = basicFixture.instantiate();
     element.repoName = 'test-repo' as RepoName;
@@ -67,9 +63,9 @@
       work_in_progress: true,
     };
 
-    const saveStub = sinon
-      .stub(element.$.restAPI, 'createChange')
-      .callsFake(() => Promise.resolve(createChange()));
+    const saveStub = stubRestApi('createChange').returns(
+      Promise.resolve(createChange())
+    );
 
     element.branch = 'test-branch' as BranchName;
     element.topic = 'test-topic';
@@ -103,9 +99,9 @@
       work_in_progress: true,
     };
 
-    const saveStub = sinon
-      .stub(element.$.restAPI, 'createChange')
-      .callsFake(() => Promise.resolve(createChange()));
+    const saveStub = stubRestApi('createChange').returns(
+      Promise.resolve(createChange())
+    );
 
     element.branch = 'test-branch' as BranchName;
     element.topic = 'test-topic';
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
index 1d8b7db..e68f6c9 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
@@ -17,7 +17,6 @@
 import '@polymer/iron-input/iron-input';
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
@@ -25,14 +24,8 @@
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {page} from '../../../utils/page-wrapper-utils';
 import {customElement, property, observe} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GroupName} from '../../../types/common';
-
-export interface GrCreateGroupDialog {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
+import {appContext} from '../../../services/app-context';
 
 @customElement('gr-create-group-dialog')
 export class GrCreateGroupDialog extends GestureEventListeners(
@@ -51,6 +44,8 @@
   @property({type: Boolean})
   _groupCreated = false;
 
+  private readonly restApiService = appContext.restApiService;
+
   _computeGroupUrl(groupId: string) {
     return getBaseUrl() + '/admin/groups/' + encodeURL(groupId, true);
   }
@@ -60,14 +55,18 @@
     this.hasNewGroupName = !!name;
   }
 
+  focus() {
+    this.shadowRoot?.querySelector('input')?.focus();
+  }
+
   handleCreateGroup() {
     const name = this._name as GroupName;
-    return this.$.restAPI.createGroup({name}).then(groupRegistered => {
+    return this.restApiService.createGroup({name}).then(groupRegistered => {
       if (groupRegistered.status !== 201) {
         return;
       }
       this._groupCreated = true;
-      return this.$.restAPI.getGroupConfig(name).then(group => {
+      return this.restApiService.getGroupConfig(name).then(group => {
         // TODO(TS): should group always defined ?
         page.show(this._computeGroupUrl(group!.group_id!));
       });
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_html.ts b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_html.ts
index d4ecc5d..daf8780 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_html.ts
@@ -38,5 +38,4 @@
       </section>
     </div>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.js b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.js
index d32ff30..af33691 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-create-group-dialog.js';
 import {page} from '../../../utils/page-wrapper-utils.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-create-group-dialog');
 
@@ -27,9 +28,6 @@
   const GROUP_NAME = 'test-group';
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(true); },
-    });
     element = basicFixture.instantiate();
   });
 
@@ -47,11 +45,8 @@
   });
 
   test('test for redirecting to group on successful creation', done => {
-    sinon.stub(element.$.restAPI, 'createGroup')
-        .returns(Promise.resolve({status: 201}));
-
-    sinon.stub(element.$.restAPI, 'getGroupConfig')
-        .returns(Promise.resolve({group_id: 551}));
+    stubRestApi('createGroup').returns(Promise.resolve({status: 201}));
+    stubRestApi('getGroupConfig').returns(Promise.resolve({group_id: 551}));
 
     const showStub = sinon.stub(page, 'show');
     element.handleCreateGroup()
@@ -62,11 +57,8 @@
   });
 
   test('test for unsuccessful group creation', done => {
-    sinon.stub(element.$.restAPI, 'createGroup')
-        .returns(Promise.resolve({status: 409}));
-
-    sinon.stub(element.$.restAPI, 'getGroupConfig')
-        .returns(Promise.resolve({group_id: 551}));
+    stubRestApi('createGroup').returns(Promise.resolve({status: 409}));
+    stubRestApi('getGroupConfig').returns(Promise.resolve({group_id: 551}));
 
     const showStub = sinon.stub(page, 'show');
     element.handleCreateGroup()
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
index e0a5042..6334670 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
@@ -18,7 +18,6 @@
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-select/gr-select';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
@@ -28,19 +27,13 @@
 import {page} from '../../../utils/page-wrapper-utils';
 import {customElement, property, observe} from '@polymer/decorators';
 import {BranchName, RepoName} from '../../../types/common';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {appContext} from '../../../services/app-context';
 
 enum DetailType {
   branches = 'branches',
   tags = 'tags',
 }
 
-export interface GrCreatePointerDialog {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
-
 @customElement('gr-create-pointer-dialog')
 export class GrCreatePointerDialog extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
@@ -75,6 +68,8 @@
     this.hasNewItemName = !!name;
   }
 
+  private readonly restApiService = appContext.restApiService;
+
   handleCreateItem() {
     if (!this.repoName) {
       throw new Error('repoName name is not set');
@@ -85,7 +80,7 @@
     const USE_HEAD = this._itemRevision ? this._itemRevision : 'HEAD';
     const url = `${getBaseUrl()}/admin/repos/${encodeURL(this.repoName, true)}`;
     if (this.itemDetail === DetailType.branches) {
-      return this.$.restAPI
+      return this.restApiService
         .createRepoBranch(this.repoName, this._itemName, {revision: USE_HEAD})
         .then(itemRegistered => {
           if (itemRegistered.status === 201) {
@@ -93,7 +88,7 @@
           }
         });
     } else if (this.itemDetail === DetailType.tags) {
-      return this.$.restAPI
+      return this.restApiService
         .createRepoTag(this.repoName, this._itemName, {
           revision: USE_HEAD,
           message: this._itemAnnotation || undefined,
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_html.ts b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_html.ts
index 0b3d81ae..452aab7 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_html.ts
@@ -80,5 +80,4 @@
       </section>
     </div>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.js b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.js
index 79a18d5..60af4d5 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-create-pointer-dialog.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-create-pointer-dialog');
 
@@ -28,17 +29,11 @@
   };
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(true); },
-    });
     element = basicFixture.instantiate();
   });
 
   test('branch created', done => {
-    sinon.stub(
-        element.$.restAPI,
-        'createRepoBranch')
-        .callsFake(() => Promise.resolve({}));
+    stubRestApi('createRepoBranch').returns(Promise.resolve({}));
 
     assert.isFalse(element.hasNewItemName);
 
@@ -57,10 +52,7 @@
   });
 
   test('tag created', done => {
-    sinon.stub(
-        element.$.restAPI,
-        'createRepoTag')
-        .callsFake(() => Promise.resolve({}));
+    stubRestApi('createRepoTag').returns(Promise.resolve({}));
 
     assert.isFalse(element.hasNewItemName);
 
@@ -79,10 +71,7 @@
   });
 
   test('tag created with annotations', done => {
-    sinon.stub(
-        element.$.restAPI,
-        'createRepoTag')
-        .callsFake(() => Promise.resolve({}));
+    stubRestApi('createRepoTag').returns(() => Promise.resolve({}));
 
     assert.isFalse(element.hasNewItemName);
 
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
index 6f0ac19..f708485 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
@@ -19,7 +19,6 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-select/gr-select';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
@@ -28,10 +27,9 @@
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {page} from '../../../utils/page-wrapper-utils';
 import {customElement, observe, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {ProjectInput, RepoName} from '../../../types/common';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {AutocompleteQuery} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {appContext} from '../../../services/app-context';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -39,12 +37,6 @@
   }
 }
 
-export interface GrCreateRepoDialog {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
-
 @customElement('gr-create-repo-dialog')
 export class GrCreateRepoDialog extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
@@ -78,6 +70,8 @@
   @property({type: Object})
   _queryGroups: AutocompleteQuery;
 
+  private readonly restApiService = appContext.restApiService;
+
   constructor() {
     super();
     this._query = (input: string) => this._getRepoSuggestions(input);
@@ -88,6 +82,10 @@
     return getBaseUrl() + '/admin/repos/' + encodeURL(repoName, true);
   }
 
+  focus() {
+    this.shadowRoot?.querySelector('input')?.focus();
+  }
+
   @observe('_repoConfig.name')
   _updateRepoName(name: string) {
     this.hasNewRepoName = !!name;
@@ -103,41 +101,31 @@
   }
 
   handleCreateRepo() {
-    return this.$.restAPI.createRepo(this._repoConfig).then(repoRegistered => {
-      if (repoRegistered.status === 201) {
-        this._repoCreated = true;
-        page.show(this._computeRepoUrl(this._repoConfig.name));
-      }
-    });
+    return this.restApiService
+      .createRepo(this._repoConfig)
+      .then(repoRegistered => {
+        if (repoRegistered.status === 201) {
+          this._repoCreated = true;
+          page.show(this._computeRepoUrl(this._repoConfig.name));
+        }
+      });
   }
 
   _getRepoSuggestions(input: string) {
-    return this.$.restAPI.getSuggestedProjects(input).then(response => {
+    return this.restApiService.getSuggestedProjects(input).then(response => {
       const repos = [];
-      for (const key in response) {
-        if (!hasOwnProperty(response, key)) {
-          continue;
-        }
-        repos.push({
-          name: key,
-          value: response[key],
-        });
+      for (const [name, project] of Object.entries(response ?? {})) {
+        repos.push({name, value: project.id});
       }
       return repos;
     });
   }
 
   _getGroupSuggestions(input: string) {
-    return this.$.restAPI.getSuggestedGroups(input).then(response => {
+    return this.restApiService.getSuggestedGroups(input).then(response => {
       const groups = [];
-      for (const key in response) {
-        if (!hasOwnProperty(response, key)) {
-          continue;
-        }
-        groups.push({
-          name: key,
-          value: decodeURIComponent(response[key].id),
-        });
+      for (const [name, group] of Object.entries(response ?? {})) {
+        groups.push({name, value: decodeURIComponent(group.id)});
       }
       return groups;
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts
index 02aabfe..070ee86 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts
@@ -99,5 +99,4 @@
       </section>
     </div>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.js b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.js
index 1e1fb0e..f10141a 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-create-repo-dialog.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-create-repo-dialog');
 
@@ -24,9 +25,6 @@
   let element;
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(true); },
-    });
     element = basicFixture.instantiate();
   });
 
@@ -44,8 +42,7 @@
       owners: ['testId'],
     };
 
-    const saveStub = sinon.stub(element.$.restAPI,
-        'createRepo').callsFake(() => Promise.resolve({}));
+    const saveStub = stubRestApi('createRepo').returns(Promise.resolve({}));
 
     assert.isFalse(element.hasNewRepoName);
 
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
index f7cffac..201b340 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
@@ -18,7 +18,6 @@
 import '../../../styles/gr-table-styles';
 import '../../../styles/shared-styles';
 import '../../shared/gr-date-formatter/gr-date-formatter';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-account-link/gr-account-link';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
@@ -28,23 +27,17 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {customElement, property} from '@polymer/decorators';
 import {
-  ErrorCallback,
-  RestApiService,
-} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {
   GroupInfo,
   AccountInfo,
   EncodedGroupId,
   GroupAuditEventInfo,
 } from '../../../types/common';
+import {firePageError, fireTitleChange} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
 
 const GROUP_EVENTS = ['ADD_GROUP', 'REMOVE_GROUP'];
 
-export interface GrGroupAuditLog {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
 @customElement('gr-group-audit-log')
 export class GrGroupAuditLog extends ListViewMixin(
   GestureEventListeners(LegacyElementMixin(PolymerElement))
@@ -62,16 +55,12 @@
   @property({type: Boolean})
   _loading = true;
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
   attached() {
     super.attached();
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'Audit Log'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, 'Audit Log');
   }
 
   /** @override */
@@ -86,16 +75,10 @@
     }
 
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(response);
     };
 
-    return this.$.restAPI
+    return this.restApiService
       .getGroupAuditLog(this.groupId, errFn)
       .then(auditLog => {
         if (!auditLog) {
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.ts b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.ts
index 1212685..32db13f 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.ts
@@ -66,5 +66,4 @@
       </template>
     </tbody>
   </table>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.js b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.js
index 1bbfcae..268112e 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.js
@@ -17,6 +17,8 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-group-audit-log.js';
+import {stubRestApi} from '../../../test/test-utils.js';
+import {addListenerForTest} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-group-audit-log');
 
@@ -79,13 +81,11 @@
       element.groupId = 1;
 
       const response = {status: 404};
-      sinon.stub(
-          element.$.restAPI, 'getGroupAuditLog')
-          .callsFake((group, errFn) => {
-            errFn(response);
-          });
+      stubRestApi('getGroupAuditLog').callsFake((group, errFn) => {
+        errFn(response);
+      });
 
-      element.addEventListener('page-error', e => {
+      addListenerForTest(document, 'page-error', e => {
         assert.deepEqual(e.detail.response, response);
         done();
       });
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..54f58c2 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
@@ -23,7 +23,6 @@
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
@@ -31,10 +30,6 @@
 import {htmlTemplate} from './gr-group-members_html';
 import {getBaseUrl} from '../../../utils/url-util';
 import {customElement, property} from '@polymer/decorators';
-import {
-  RestApiService,
-  ErrorCallback,
-} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {
   GroupId,
@@ -43,9 +38,18 @@
   GroupInfo,
   GroupName,
 } from '../../../types/common';
-import {AutocompleteQuery} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {
+  AutocompleteQuery,
+  AutocompleteSuggestion,
+} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {PolymerDomRepeatEvent} from '../../../types/types';
-import {hasOwnProperty} from '../../../utils/common-util';
+import {
+  fireAlert,
+  firePageError,
+  fireTitleChange,
+} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
 
 const SUGGESTIONS_LIMIT = 15;
 const SAVING_ERROR_TEXT =
@@ -55,7 +59,6 @@
 
 export interface GrGroupMembers {
   $: {
-    restAPI: RestApiService & Element;
     overlay: GrOverlay;
   };
 }
@@ -114,6 +117,8 @@
 
   _itemId?: AccountId | GroupId;
 
+  private readonly restApiService = appContext.restApiService;
+
   constructor() {
     super();
     this._queryMembers = input => this._getAccountSuggestions(input);
@@ -125,13 +130,7 @@
     super.attached();
     this._loadGroupDetails();
 
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'Members'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, 'Members');
   }
 
   _loadGroupDetails() {
@@ -142,50 +141,48 @@
     const promises: Promise<void>[] = [];
 
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(response);
     };
 
-    return this.$.restAPI.getGroupConfig(this.groupId, errFn).then(config => {
-      if (!config || !config.name) {
-        return Promise.resolve();
-      }
+    return this.restApiService
+      .getGroupConfig(this.groupId, errFn)
+      .then(config => {
+        if (!config || !config.name) {
+          return Promise.resolve();
+        }
 
-      this._groupName = config.name;
+        this._groupName = config.name;
 
-      promises.push(
-        this.$.restAPI.getIsAdmin().then(isAdmin => {
-          this._isAdmin = !!isAdmin;
-        })
-      );
+        promises.push(
+          this.restApiService.getIsAdmin().then(isAdmin => {
+            this._isAdmin = !!isAdmin;
+          })
+        );
 
-      promises.push(
-        this.$.restAPI.getIsGroupOwner(this._groupName).then(isOwner => {
-          this._groupOwner = !!isOwner;
-        })
-      );
+        promises.push(
+          this.restApiService.getIsGroupOwner(this._groupName).then(isOwner => {
+            this._groupOwner = !!isOwner;
+          })
+        );
 
-      promises.push(
-        this.$.restAPI.getGroupMembers(this._groupName).then(members => {
-          this._groupMembers = members;
-        })
-      );
+        promises.push(
+          this.restApiService.getGroupMembers(this._groupName).then(members => {
+            this._groupMembers = members;
+          })
+        );
 
-      promises.push(
-        this.$.restAPI.getIncludedGroup(this._groupName).then(includedGroup => {
-          this._includedGroups = includedGroup;
-        })
-      );
+        promises.push(
+          this.restApiService
+            .getIncludedGroup(this._groupName)
+            .then(includedGroup => {
+              this._includedGroups = includedGroup;
+            })
+        );
 
-      return Promise.all(promises).then(() => {
-        this._loading = false;
+        return Promise.all(promises).then(() => {
+          this._loading = false;
+        });
       });
-    });
   }
 
   _computeLoadingClass(loading: boolean) {
@@ -217,13 +214,13 @@
     if (!this._groupName) {
       return Promise.reject(new Error('group name undefined'));
     }
-    return this.$.restAPI
+    return this.restApiService
       .saveGroupMember(this._groupName, this._groupMemberSearchId as AccountId)
       .then(config => {
         if (!config || !this._groupName) {
           return;
         }
-        this.$.restAPI.getGroupMembers(this._groupName).then(members => {
+        this.restApiService.getGroupMembers(this._groupName).then(members => {
           this._groupMembers = members;
         });
         this._groupMemberSearchName = '';
@@ -237,24 +234,26 @@
     }
     this.$.overlay.close();
     if (this._itemType === 'member') {
-      return this.$.restAPI
+      return this.restApiService
         .deleteGroupMember(this._groupName, this._itemId! as AccountId)
         .then(itemDeleted => {
           if (itemDeleted.status === 204 && this._groupName) {
-            this.$.restAPI.getGroupMembers(this._groupName).then(members => {
-              this._groupMembers = members;
-            });
+            this.restApiService
+              .getGroupMembers(this._groupName)
+              .then(members => {
+                this._groupMembers = members;
+              });
           }
         });
     } else if (this._itemType === 'includedGroup') {
-      return this.$.restAPI
+      return this.restApiService
         .deleteIncludedGroup(this._groupName, this._itemId! as GroupId)
         .then(itemDeleted => {
           if (
             (itemDeleted.status === 204 || itemDeleted.status === 205) &&
             this._groupName
           ) {
-            this.$.restAPI
+            this.restApiService
               .getIncludedGroup(this._groupName)
               .then(includedGroup => {
                 this._includedGroups = includedGroup;
@@ -290,20 +289,14 @@
         new Error('group name or includedGroupSearchId undefined')
       );
     }
-    return this.$.restAPI
+    return this.restApiService
       .saveIncludedGroup(
         this._groupName,
         this._includedGroupSearchId.replace(/\+/g, ' ') as GroupId,
         (errResponse, err) => {
           if (errResponse) {
             if (errResponse.status === 404) {
-              this.dispatchEvent(
-                new CustomEvent('show-alert', {
-                  detail: {message: SAVING_ERROR_TEXT},
-                  bubbles: true,
-                  composed: true,
-                })
-              );
+              fireAlert(this, SAVING_ERROR_TEXT);
               return errResponse;
             }
             throw Error(errResponse.statusText);
@@ -315,9 +308,11 @@
         if (!config || !this._groupName) {
           return;
         }
-        this.$.restAPI.getIncludedGroup(this._groupName).then(includedGroup => {
-          this._includedGroups = includedGroup;
-        });
+        this.restApiService
+          .getIncludedGroup(this._groupName)
+          .then(includedGroup => {
+            this._includedGroups = includedGroup;
+          });
         this._includedGroupSearchName = '';
         this._includedGroupSearchId = '';
       });
@@ -343,26 +338,21 @@
     if (input.length === 0) {
       return Promise.resolve([]);
     }
-    return this.$.restAPI
+    return this.restApiService
       .getSuggestedAccounts(input, SUGGESTIONS_LIMIT)
       .then(accounts => {
+        if (!accounts) return [];
         const accountSuggestions = [];
-        let nameAndEmail;
-        if (!accounts) {
-          return [];
-        }
-        for (const key in accounts) {
-          if (!hasOwnProperty(accounts, key)) {
-            continue;
-          }
-          if (accounts[key].email !== undefined) {
-            nameAndEmail = `${accounts[key].name} <${accounts[key].email}>`;
+        for (const account of accounts) {
+          let nameAndEmail;
+          if (account.email !== undefined) {
+            nameAndEmail = `${account.name} <${account.email}>`;
           } else {
-            nameAndEmail = accounts[key].name;
+            nameAndEmail = account.name;
           }
           accountSuggestions.push({
             name: nameAndEmail,
-            value: accounts[key]._account_id,
+            value: account._account_id?.toString(),
           });
         }
         return accountSuggestions;
@@ -370,16 +360,10 @@
   }
 
   _getGroupSuggestions(input: string) {
-    return this.$.restAPI.getSuggestedGroups(input).then(response => {
-      const groups = [];
-      for (const key in response) {
-        if (!hasOwnProperty(response, key)) {
-          continue;
-        }
-        groups.push({
-          name: key,
-          value: decodeURIComponent(response[key].id),
-        });
+    return this.restApiService.getSuggestedGroups(input).then(response => {
+      const groups: AutocompleteSuggestion[] = [];
+      for (const [name, group] of Object.entries(response ?? {})) {
+        groups.push({name, value: decodeURIComponent(group.id)});
       }
       return groups;
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.ts
index 2d3f8fc..272cb09 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.ts
@@ -56,8 +56,8 @@
       display: none;
     }
   </style>
-  <main
-    class$="gr-form-styles [[_computeHideItemClass(_groupOwner, _isAdmin)]]"
+  <div
+    class$="main gr-form-styles [[_computeHideItemClass(_groupOwner, _isAdmin)]]"
   >
     <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
       Loading...
@@ -170,7 +170,7 @@
         </fieldset>
       </div>
     </div>
-  </main>
+  </div>
   <gr-overlay id="overlay" with-backdrop="">
     <gr-confirm-delete-item-dialog
       class="confirmDialog"
@@ -180,5 +180,4 @@
       item-type="[[_itemType]]"
     ></gr-confirm-delete-item-dialog>
   </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
index b5d2217..672ac07 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
@@ -18,7 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-group-members.js';
 import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {stubBaseUrl} from '../../../test/test-utils.js';
+import {addListenerForTest, stubBaseUrl, stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-group-members');
 
@@ -78,70 +78,52 @@
     },
     ];
 
-    stub('gr-rest-api-interface', {
-      getSuggestedAccounts(input) {
-        if (input.startsWith('test')) {
-          return Promise.resolve([
-            {
-              _account_id: 1000096,
-              name: 'test-account',
-              email: 'test.account@example.com',
-              username: 'test123',
-            },
-            {
-              _account_id: 1001439,
-              name: 'test-admin',
-              email: 'test.admin@example.com',
-              username: 'test_admin',
-            },
-            {
-              _account_id: 1001439,
-              name: 'test-git',
-              username: 'test_git',
-            },
-          ]);
-        } else {
-          return Promise.resolve({});
-        }
-      },
-      getSuggestedGroups(input) {
-        if (input.startsWith('test')) {
-          return Promise.resolve({
-            'test-admin': {
-              id: '1ce023d3fb4e4260776fb92cd08b52bbd21ce70a',
-            },
-            'test/Administrator (admin)': {
-              id: 'test%3Aadmin',
-            },
-          });
-        } else {
-          return Promise.resolve({});
-        }
-      },
-      getLoggedIn() { return Promise.resolve(true); },
-      getConfig() {
-        return Promise.resolve();
-      },
-      getGroupMembers() {
-        return Promise.resolve(groupMembers);
-      },
-      getIsGroupOwner() {
-        return Promise.resolve(true);
-      },
-      getIncludedGroup() {
-        return Promise.resolve(includedGroups);
-      },
-      getAccountCapabilities() {
-        return Promise.resolve();
-      },
+    stubRestApi('getSuggestedAccounts').callsFake(input => {
+      if (input.startsWith('test')) {
+        return Promise.resolve([
+          {
+            _account_id: 1000096,
+            name: 'test-account',
+            email: 'test.account@example.com',
+            username: 'test123',
+          },
+          {
+            _account_id: 1001439,
+            name: 'test-admin',
+            email: 'test.admin@example.com',
+            username: 'test_admin',
+          },
+          {
+            _account_id: 1001439,
+            name: 'test-git',
+            username: 'test_git',
+          },
+        ]);
+      } else {
+        return Promise.resolve([]);
+      }
     });
+    stubRestApi('getSuggestedGroups').callsFake(input => {
+      if (input.startsWith('test')) {
+        return Promise.resolve({
+          'test-admin': {
+            id: '1ce023d3fb4e4260776fb92cd08b52bbd21ce70a',
+          },
+          'test/Administrator (admin)': {
+            id: 'test%3Aadmin',
+          },
+        });
+      } else {
+        return Promise.resolve({});
+      }
+    });
+    stubRestApi('getGroupMembers').returns(Promise.resolve(groupMembers));
+    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
+    stubRestApi('getIncludedGroup').returns(Promise.resolve(includedGroups));
     element = basicFixture.instantiate();
     stubBaseUrl('https://test/site');
     element.groupId = 1;
-    groupStub = sinon.stub(
-        element.$.restAPI,
-        'getGroupConfig')
-        .callsFake(() => Promise.resolve(groups));
+    groupStub = stubRestApi('getGroupConfig').returns(Promise.resolve(groups));
     return element._loadGroupDetails();
   });
 
@@ -162,7 +144,7 @@
 
     const memberName = 'test-admin';
 
-    const saveStub = sinon.stub(element.$.restAPI, 'saveGroupMember')
+    const saveStub = stubRestApi('saveGroupMember')
         .callsFake(() => Promise.resolve({}));
 
     const button = element.$.saveGroupMember;
@@ -187,8 +169,7 @@
 
     const includedGroupName = 'testName';
 
-    const saveIncludedGroupStub = sinon.stub(
-        element.$.restAPI, 'saveIncludedGroup')
+    const saveIncludedGroupStub = stubRestApi('saveIncludedGroup')
         .callsFake(() => Promise.resolve({}));
 
     const button = element.$.saveIncludedGroups;
@@ -219,8 +200,14 @@
       status: 404,
       ok: false,
     };
-    sinon.stub(element.$.restAPI._restApiHelper, 'fetch').callsFake(
-        () => Promise.resolve(errorResponse));
+    stubRestApi('saveIncludedGroup').callsFake((
+        groupName,
+        includedGroup,
+        errFn
+    ) => {
+      errFn(errorResponse);
+      return Promise.resolve(undefined);
+    });
 
     element.$.groupMemberSearchInput.text = memberName;
     element.$.groupMemberSearchInput.value = 1234;
@@ -232,13 +219,8 @@
 
   test('add included group network-error throws an exception', async () => {
     element._groupOwner = true;
-
     const memberName = 'bad-name';
-    const alertStub = sinon.stub();
-    element.addEventListener('show-alert', alertStub);
-    const err = new Error();
-    sinon.stub(element.$.restAPI._restApiHelper, 'fetch').callsFake(
-        () => Promise.reject(err));
+    stubRestApi('saveIncludedGroup').throws(new Error());
 
     element.$.groupMemberSearchInput.text = memberName;
     element.$.groupMemberSearchInput.value = 1234;
@@ -366,12 +348,11 @@
     element.groupId = 1;
 
     const response = {status: 404};
-    sinon.stub(
-        element.$.restAPI, 'getGroupConfig')
+    stubRestApi('getGroupConfig')
         .callsFake((group, errFn) => {
           errFn(response);
         });
-    element.addEventListener('page-error', e => {
+    addListenerForTest(document, 'page-error', e => {
       assert.deepEqual(e.detail.response, response);
       done();
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
index 511bf5c..84daef8 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -21,7 +21,6 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-select/gr-select';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
@@ -34,10 +33,12 @@
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {GroupId, GroupInfo, GroupName} from '../../../types/common';
 import {
-  ErrorCallback,
-  RestApiService,
-} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {hasOwnProperty} from '../../../utils/common-util';
+  fireEvent,
+  firePageError,
+  fireTitleChange,
+} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
 
 const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
 
@@ -54,7 +55,6 @@
 
 export interface GrGroup {
   $: {
-    restAPI: RestApiService & Element;
     loading: HTMLDivElement;
   };
 }
@@ -126,6 +126,8 @@
   @property({type: Boolean})
   _isAdmin = false;
 
+  private readonly restApiService = appContext.restApiService;
+
   constructor() {
     super();
     this._query = (input: string) => this._getGroupSuggestions(input);
@@ -145,56 +147,46 @@
     const promises: Promise<unknown>[] = [];
 
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(response);
     };
 
-    return this.$.restAPI.getGroupConfig(this.groupId, errFn).then(config => {
-      if (!config || !config.name) {
-        return Promise.resolve();
-      }
+    return this.restApiService
+      .getGroupConfig(this.groupId, errFn)
+      .then(config => {
+        if (!config || !config.name) {
+          return Promise.resolve();
+        }
 
-      this._groupName = config.name;
-      this._groupIsInternal = !!config.id.match(INTERNAL_GROUP_REGEX);
+        this._groupName = config.name;
+        this._groupIsInternal = !!config.id.match(INTERNAL_GROUP_REGEX);
 
-      promises.push(
-        this.$.restAPI.getIsAdmin().then(isAdmin => {
-          this._isAdmin = !!isAdmin;
-        })
-      );
+        promises.push(
+          this.restApiService.getIsAdmin().then(isAdmin => {
+            this._isAdmin = !!isAdmin;
+          })
+        );
 
-      promises.push(
-        this.$.restAPI.getIsGroupOwner(config.name).then(isOwner => {
-          this._groupOwner = !!isOwner;
-        })
-      );
+        promises.push(
+          this.restApiService.getIsGroupOwner(config.name).then(isOwner => {
+            this._groupOwner = !!isOwner;
+          })
+        );
 
-      // If visible to all is undefined, set to false. If it is defined
-      // as false, setting to false is fine. If any optional values
-      // are added with a default of true, then this would need to be an
-      // undefined check and not a truthy/falsy check.
-      if (config.options && !config.options.visible_to_all) {
-        config.options.visible_to_all = false;
-      }
-      this._groupConfig = config;
+        // If visible to all is undefined, set to false. If it is defined
+        // as false, setting to false is fine. If any optional values
+        // are added with a default of true, then this would need to be an
+        // undefined check and not a truthy/falsy check.
+        if (config.options && !config.options.visible_to_all) {
+          config.options.visible_to_all = false;
+        }
+        this._groupConfig = config;
 
-      this.dispatchEvent(
-        new CustomEvent('title-change', {
-          detail: {title: config.name},
-          composed: true,
-          bubbles: true,
-        })
-      );
+        fireTitleChange(this, config.name);
 
-      return Promise.all(promises).then(() => {
-        this._loading = false;
+        return Promise.all(promises).then(() => {
+          this._loading = false;
+        });
       });
-    });
   }
 
   _computeLoadingClass(loading: boolean) {
@@ -211,7 +203,7 @@
       return Promise.reject(new Error('invalid groupId or config name'));
     }
     const groupName = groupConfig.name;
-    return this.$.restAPI
+    return this.restApiService
       .saveGroupName(this.groupId, groupName)
       .then(config => {
         if (config.status === 200) {
@@ -220,6 +212,7 @@
             name: groupName,
             external: !this._groupIsInternal,
           };
+          fireEvent(this, 'name-changed');
           this.dispatchEvent(
             new CustomEvent('name-changed', {
               detail,
@@ -239,7 +232,7 @@
       owner = decodeURIComponent(this._groupConfigOwner);
     }
     if (!owner) return;
-    return this.$.restAPI.saveGroupOwner(this.groupId, owner).then(() => {
+    return this.restApiService.saveGroupOwner(this.groupId, owner).then(() => {
       this._owner = false;
     });
   }
@@ -247,7 +240,7 @@
   _handleSaveDescription() {
     if (!this.groupId || !this._groupConfig || !this._groupConfig.description)
       return;
-    return this.$.restAPI
+    return this.restApiService
       .saveGroupDescription(this.groupId, this._groupConfig.description)
       .then(() => {
         this._description = false;
@@ -261,9 +254,11 @@
 
     const options = {visible_to_all: visible};
 
-    return this.$.restAPI.saveGroupOptions(this.groupId, options).then(() => {
-      this._options = false;
-    });
+    return this.restApiService
+      .saveGroupOptions(this.groupId, options)
+      .then(() => {
+        this._options = false;
+      });
   }
 
   @observe('_groupConfig.name')
@@ -303,16 +298,10 @@
   }
 
   _getGroupSuggestions(input: string) {
-    return this.$.restAPI.getSuggestedGroups(input).then(response => {
+    return this.restApiService.getSuggestedGroups(input).then(response => {
       const groups: AutocompleteSuggestion[] = [];
-      for (const key in response) {
-        if (!hasOwnProperty(response, key)) {
-          continue;
-        }
-        groups.push({
-          name: key,
-          value: decodeURIComponent(response[key].id),
-        });
+      for (const [name, group] of Object.entries(response ?? {})) {
+        groups.push({name, value: decodeURIComponent(group.id)});
       }
       return groups;
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.ts
index aed73bf..ba089f6 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.ts
@@ -32,7 +32,7 @@
   <style include="gr-form-styles">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
   </style>
-  <main class="gr-form-styles read-only">
+  <div class="main gr-form-styles read-only">
     <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
       Loading...
     </div>
@@ -164,6 +164,5 @@
         </fieldset>
       </div>
     </div>
-  </main>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </div>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js
index 34f6b6a..4c09e30 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js
@@ -17,6 +17,8 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-group.js';
+import {stubRestApi} from '../../../test/test-utils.js';
+import {addListenerForTest} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-group');
 
@@ -36,14 +38,8 @@
   };
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(true); },
-    });
     element = basicFixture.instantiate();
-    groupStub = sinon.stub(
-        element.$.restAPI,
-        'getGroupConfig')
-        .callsFake(() => Promise.resolve(group));
+    groupStub = stubRestApi('getGroupConfig').returns(Promise.resolve(group));
   });
 
   test('loading displays before group config is loaded', () => {
@@ -55,10 +51,7 @@
   });
 
   test('default values are populated with internal group', done => {
-    sinon.stub(
-        element.$.restAPI,
-        'getIsGroupOwner')
-        .callsFake(() => Promise.resolve(true));
+    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
     element.groupId = 1;
     element._loadGroup().then(() => {
       assert.isTrue(element._groupIsInternal);
@@ -71,14 +64,9 @@
     const groupExternal = {...group};
     groupExternal.id = 'external-group-id';
     groupStub.restore();
-    groupStub = sinon.stub(
-        element.$.restAPI,
-        'getGroupConfig')
-        .callsFake(() => Promise.resolve(groupExternal));
-    sinon.stub(
-        element.$.restAPI,
-        'getIsGroupOwner')
-        .callsFake(() => Promise.resolve(true));
+    groupStub = stubRestApi('getGroupConfig').returns(
+        Promise.resolve(groupExternal));
+    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
     element.groupId = 1;
     element._loadGroup().then(() => {
       assert.isFalse(element._groupIsInternal);
@@ -96,15 +84,8 @@
     };
     element._groupName = groupName;
 
-    sinon.stub(
-        element.$.restAPI,
-        'getIsGroupOwner')
-        .callsFake(() => Promise.resolve(true));
-
-    sinon.stub(
-        element.$.restAPI,
-        'saveGroupName')
-        .callsFake(() => Promise.resolve({status: 200}));
+    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
+    stubRestApi('saveGroupName').returns(Promise.resolve({status: 200}));
 
     const button = element.$.inputUpdateNameBtn;
 
@@ -135,10 +116,7 @@
     element._groupConfigOwner = 'testId';
     element._groupOwner = true;
 
-    sinon.stub(
-        element.$.restAPI,
-        'getIsGroupOwner')
-        .callsFake(() => Promise.resolve({status: 200}));
+    stubRestApi('getIsGroupOwner').returns(Promise.resolve({status: 200}));
 
     const button = element.$.inputUpdateOwnerBtn;
 
@@ -162,10 +140,7 @@
   test('test for undefined group name', done => {
     groupStub.restore();
 
-    sinon.stub(
-        element.$.restAPI,
-        'getGroupConfig')
-        .callsFake(() => Promise.resolve({}));
+    stubRestApi('getGroupConfig').returns(Promise.resolve({}));
 
     assert.isUndefined(element.groupId);
 
@@ -189,8 +164,7 @@
       name: 'test-group',
     };
     element.groupId = 'gg';
-    sinon.stub(element.$.restAPI, 'saveGroupName')
-        .returns(Promise.resolve({status: 200}));
+    stubRestApi('saveGroupName').returns(Promise.resolve({status: 200}));
 
     const showStub = sinon.stub(element, 'dispatchEvent');
     element._handleSaveName()
@@ -239,12 +213,11 @@
     element.groupId = 1;
 
     const response = {status: 404};
-    sinon.stub(
-        element.$.restAPI, 'getGroupConfig').callsFake((group, errFn) => {
+    stubRestApi('getGroupConfig').callsFake((group, errFn) => {
       errFn(response);
     });
 
-    element.addEventListener('page-error', e => {
+    addListenerForTest(document, 'page-error', e => {
       assert.deepEqual(e.detail.response, response);
       done();
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
index c998cd8..85ba052 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
@@ -21,7 +21,6 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-rule-editor/gr-rule-editor';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
@@ -34,8 +33,6 @@
   PermissionArray,
 } from '../../../utils/access-util';
 import {customElement, property, observe} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {
   LabelNameToLabelTypeInfoMap,
   LabelTypeInfoValues,
@@ -56,6 +53,8 @@
   EditableProjectAccessGroups,
 } from '../gr-repo-access/gr-repo-access-interfaces';
 import {PolymerDomRepeatEvent} from '../../../types/types';
+import {appContext} from '../../../services/app-context';
+import {fireEvent} from '../../../utils/event-util';
 
 const MAX_AUTOCOMPLETE_RESULTS = 20;
 
@@ -65,7 +64,6 @@
 
 export interface GrPermission {
   $: {
-    restAPI: RestApiService & Element;
     groupAutocomplete: GrAutocomplete;
   };
 }
@@ -142,6 +140,8 @@
   @property({type: Boolean})
   _originalExclusiveValue?: boolean;
 
+  private readonly restApiService = appContext.restApiService;
+
   constructor() {
     super();
     this._query = () => this._getGroupSuggestions();
@@ -222,9 +222,7 @@
     }
     this.permission.value.modified = true;
     // Allows overall access page to know a change has been made.
-    this.dispatchEvent(
-      new CustomEvent('access-modified', {bubbles: true, composed: true})
-    );
+    fireEvent(this, 'access-modified');
   }
 
   _handleRemovePermission() {
@@ -232,18 +230,11 @@
       return;
     }
     if (this.permission.value.added) {
-      this.dispatchEvent(
-        new CustomEvent('added-permission-removed', {
-          bubbles: true,
-          composed: true,
-        })
-      );
+      fireEvent(this, 'added-permission-removed');
     }
     this._deleted = true;
     this.permission.value.deleted = true;
-    this.dispatchEvent(
-      new CustomEvent('access-modified', {bubbles: true, composed: true})
-    );
+    fireEvent(this, 'access-modified');
   }
 
   @observe('_rules.splices')
@@ -337,18 +328,12 @@
   }
 
   _getGroupSuggestions(): Promise<AutocompleteSuggestion[]> {
-    return this.$.restAPI
+    return this.restApiService
       .getSuggestedGroups(this._groupFilter || '', MAX_AUTOCOMPLETE_RESULTS)
       .then(response => {
         const groups: GroupSuggestion[] = [];
-        for (const key in response) {
-          if (!hasOwnProperty(response, key)) {
-            continue;
-          }
-          groups.push({
-            name: key,
-            value: response[key],
-          });
+        for (const [name, value] of Object.entries(response ?? {})) {
+          groups.push({name, value});
         }
         // Does not return groups in which we already have rules for.
         return groups
@@ -407,9 +392,7 @@
     value.added = true;
     // See comment above for why we cannot use "this.set(...)" here.
     this.permission.value.rules[groupId] = value;
-    this.dispatchEvent(
-      new CustomEvent('access-modified', {bubbles: true, composed: true})
-    );
+    fireEvent(this, 'access-modified');
   }
 
   _computeHasRange(name: string) {
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.ts
index 9795c92..6ae982b 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.ts
@@ -140,5 +140,4 @@
     </div>
     <!-- end deletedContainer -->
   </section>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.js b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.js
index 32430ec..d5668d8 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-permission.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-permission');
 
@@ -25,7 +26,7 @@
 
   setup(() => {
     element = basicFixture.instantiate();
-    sinon.stub(element.$.restAPI, 'getSuggestedGroups').returns(
+    stubRestApi('getSuggestedGroups').returns(
         Promise.resolve({
           'Administrators': {
             id: '4c97682e6ce61b7247f3381b6f1789356666de7f',
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
index 5039972..f5e9a92 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
@@ -17,7 +17,6 @@
 import '../../../styles/gr-table-styles';
 import '../../../styles/shared-styles';
 import '../../shared/gr-list-view/gr-list-view';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
@@ -27,18 +26,15 @@
   ListViewParams,
 } from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
 import {customElement, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {ErrorCallback} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {PluginInfo} from '../../../types/common';
+import {firePageError} from '../../../utils/event-util';
+import {fireTitleChange} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
 
 interface PluginInfoWithName extends PluginInfo {
   name: string;
 }
-export interface GrPluginList {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
 @customElement('gr-plugin-list')
 export class GrPluginList extends ListViewMixin(
   GestureEventListeners(LegacyElementMixin(PolymerElement))
@@ -81,16 +77,12 @@
   @property({type: String})
   _filter = '';
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
   attached() {
     super.attached();
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'Plugins'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, 'Plugins');
   }
 
   _paramsChanged(params: ListViewParams) {
@@ -103,15 +95,9 @@
 
   _getPlugins(filter: string, pluginsPerPage: number, offset?: number) {
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(response);
     };
-    return this.$.restAPI
+    return this.restApiService
       .getPlugins(filter, pluginsPerPage, offset, errFn)
       .then(plugins => {
         if (!plugins) {
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_html.ts b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_html.ts
index d5318b5..eeca478 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_html.ts
@@ -78,5 +78,4 @@
       </tbody>
     </table>
   </gr-list-view>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.js b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.js
index 7303748..a9281e4 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-plugin-list.js';
 import 'lodash/lodash.js';
+import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-plugin-list');
 
@@ -54,13 +55,7 @@
   suite('list with plugins', () => {
     setup(done => {
       plugins = _.times(26, pluginGenerator);
-
-      stub('gr-rest-api-interface', {
-        getPlugins(num, offset) {
-          return Promise.resolve(plugins);
-        },
-      });
-
+      stubRestApi('getPlugins').returns(Promise.resolve(plugins));
       element._paramsChanged(value).then(() => { flush(done); });
     });
 
@@ -113,13 +108,7 @@
   suite('list with less then 26 plugins', () => {
     setup(done => {
       plugins = _.times(25, pluginGenerator);
-
-      stub('gr-rest-api-interface', {
-        getPlugins(num, offset) {
-          return Promise.resolve(plugins);
-        },
-      });
-
+      stubRestApi('getPlugins').returns(Promise.resolve(plugins));
       element._paramsChanged(value).then(() => { flush(done); });
     });
 
@@ -129,24 +118,17 @@
   });
 
   suite('filter', () => {
-    test('_paramsChanged', done => {
-      sinon.stub(
-          element.$.restAPI,
-          'getPlugins')
-          .callsFake(() => Promise.resolve(plugins));
+    test('_paramsChanged', async () => {
+      const getPluginsStub = stubRestApi('getPlugins');
+      getPluginsStub.returns(Promise.resolve(plugins));
       const value = {
         filter: 'test',
         offset: 25,
       };
-      element._paramsChanged(value).then(() => {
-        assert.equal(element.$.restAPI.getPlugins.lastCall.args[0],
-            'test');
-        assert.equal(element.$.restAPI.getPlugins.lastCall.args[1],
-            25);
-        assert.equal(element.$.restAPI.getPlugins.lastCall.args[2],
-            25);
-        done();
-      });
+      await element._paramsChanged(value);
+      assert.equal(getPluginsStub.lastCall.args[0], 'test');
+      assert.equal(getPluginsStub.lastCall.args[1], 25);
+      assert.equal(getPluginsStub.lastCall.args[2], 25);
     });
   });
 
@@ -168,12 +150,12 @@
   suite('404', () => {
     test('fires page-error', done => {
       const response = {status: 404};
-      sinon.stub(element.$.restAPI, 'getPlugins').callsFake(
+      stubRestApi('getPlugins').callsFake(
           (filter, pluginsPerPage, opt_offset, errFn) => {
             errFn(response);
           });
 
-      element.addEventListener('page-error', e => {
+      addListenerForTest(document, 'page-error', e => {
         assert.deepEqual(e.detail.response, response);
         done();
       });
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..56c5733 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
@@ -17,7 +17,6 @@
 import '../../../styles/gr-menu-page-styles';
 import '../../../styles/gr-subpage-styles';
 import '../../../styles/shared-styles';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-access-section/gr-access-section';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
@@ -38,8 +37,6 @@
   UrlEncodedRepoName,
   ProjectAccessGroups,
 } from '../../../types/common';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrAccessSection} from '../gr-access-section/gr-access-section';
 import {
@@ -52,17 +49,14 @@
   PropertyTreeNode,
   PrimitiveValue,
 } from './gr-repo-access-interfaces';
+import {firePageError, fireAlert} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {WebLinkInfo} from '../../../types/diff';
 
 const NOTHING_TO_SAVE = 'No changes to save.';
 
 const MAX_AUTOCOMPLETE_RESULTS = 50;
 
-export interface GrRepoAccess {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
-
 /**
  * Fired when save is a no-op
  *
@@ -119,12 +113,14 @@
   _sections?: PermissionAccessSection[];
 
   @property({type: Array})
-  _weblinks?: string[];
+  _weblinks?: WebLinkInfo[];
 
   @property({type: Boolean})
   _loading = true;
 
-  private _originalInheritsFrom?: ProjectInfo | null;
+  private originalInheritsFrom?: ProjectInfo | null;
+
+  private readonly restApiService = appContext.restApiService;
 
   constructor() {
     super();
@@ -155,20 +151,14 @@
 
   _reload(repo: RepoName) {
     const errFn = (response?: Response | null) => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(response);
     };
 
     this._editing = false;
 
     // Always reset sections when a project changes.
     this._sections = [];
-    const sectionsPromises = this.$.restAPI
+    const sectionsPromises = this.restApiService
       .getRepoAccessRights(repo, errFn)
       .then(res => {
         if (!res) {
@@ -183,7 +173,7 @@
               ...res.inherits_from,
             }
           : null;
-        this._originalInheritsFrom = res.inherits_from
+        this.originalInheritsFrom = res.inherits_from
           ? {
               ...res.inherits_from,
             }
@@ -204,7 +194,7 @@
         return toSortedPermissionsArray(this._local);
       });
 
-    const capabilitiesPromises = this.$.restAPI
+    const capabilitiesPromises = this.restApiService
       .getCapabilities(errFn)
       .then(res => {
         if (!res) {
@@ -214,13 +204,15 @@
         return res;
       });
 
-    const labelsPromises = this.$.restAPI.getRepo(repo, errFn).then(res => {
-      if (!res) {
-        return Promise.resolve(undefined);
-      }
+    const labelsPromises = this.restApiService
+      .getRepo(repo, errFn)
+      .then(res => {
+        if (!res) {
+          return Promise.resolve(undefined);
+        }
 
-      return res.labels;
-    });
+        return res.labels;
+      });
 
     return Promise.all([
       sectionsPromises,
@@ -252,7 +244,7 @@
   }
 
   _getInheritFromSuggestions(): Promise<AutocompleteSuggestion[]> {
-    return this.$.restAPI
+    return this.restApiService
       .getRepos(this._inheritFromFilter, MAX_AUTOCOMPLETE_RESULTS)
       .then(response => {
         const projects: AutocompleteSuggestion[] = [];
@@ -314,7 +306,7 @@
     }
     // Restore inheritFrom.
     if (this._inheritsFrom) {
-      this._inheritsFrom = {...this._originalInheritsFrom};
+      this._inheritsFrom = {...this.originalInheritsFrom};
       this._inheritFromFilter =
         'name' in this._inheritsFrom ? this._inheritsFrom.name : undefined;
     }
@@ -377,11 +369,9 @@
   /**
    * Used to recursively remove any objects with a 'deleted' bit.
    */
-  _recursivelyRemoveDeleted(obj: PropertyTreeNode) {
-    for (const k in obj) {
-      if (!hasOwnProperty(obj, k)) {
-        continue;
-      }
+  _recursivelyRemoveDeleted(obj?: PropertyTreeNode) {
+    if (!obj) return;
+    for (const k of Object.keys(obj)) {
       const node = obj[k];
       if (typeof node === 'object') {
         if (node.deleted) {
@@ -394,17 +384,15 @@
   }
 
   _recursivelyUpdateAddRemoveObj(
-    obj: PropertyTreeNode,
+    obj: PropertyTreeNode | undefined,
     addRemoveObj: {
       add: PropertyTreeNode;
       remove: PropertyTreeNode;
     },
     path: string[] = []
   ) {
-    for (const k in obj) {
-      if (!hasOwnProperty(obj, k)) {
-        continue;
-      }
+    if (!obj) return;
+    for (const k of Object.keys(obj)) {
       const node = obj[k];
       if (typeof node === 'object') {
         const updatedId = node.updatedId;
@@ -458,8 +446,8 @@
       remove: {},
     };
 
-    const originalInheritsFromId = this._originalInheritsFrom
-      ? singleDecodeURL(this._originalInheritsFrom.id)
+    const originalInheritsFromId = this.originalInheritsFrom
+      ? singleDecodeURL(this.originalInheritsFrom.id)
       : null;
     // TODO(TS): this._inheritsFrom as ProjectInfo might be a mistake.
     // _inheritsFrom can be {}
@@ -516,13 +504,7 @@
       !Object.keys(addRemoveObj.remove).length &&
       !addRemoveObj.parent
     ) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: NOTHING_TO_SAVE},
-          bubbles: true,
-          composed: true,
-        })
-      );
+      fireAlert(this, NOTHING_TO_SAVE);
       return;
     }
     const obj: ProjectAccessInput = ({
@@ -548,7 +530,7 @@
     if (!repo) {
       return Promise.resolve();
     }
-    return this.$.restAPI
+    return this.restApiService
       .setRepoAccessRights(repo, obj)
       .then(() => {
         this._reload(repo);
@@ -573,7 +555,7 @@
     if (!this.repo) {
       return;
     }
-    return this.$.restAPI
+    return this.restApiService
       .setRepoAccessRightsForReview(this.repo, obj)
       .then(change => {
         GerritNav.navigateToChange(change);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.ts
index 4e76360..c9af6f0 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.ts
@@ -60,7 +60,7 @@
   <style include="gr-menu-page-styles">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
   </style>
-  <main class$="[[_computeMainClass(_ownerOf, _canUpload, _editing)]]">
+  <div class$="main [[_computeMainClass(_ownerOf, _canUpload, _editing)]]">
     <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
       Loading...
     </div>
@@ -142,6 +142,5 @@
         >
       </div>
     </div>
-  </main>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </div>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.js b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.js
index d3204e1..a4e019e 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.js
@@ -20,6 +20,7 @@
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {toSortedPermissionsArray} from '../../../utils/access-util.js';
+import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-repo-access');
 
@@ -101,11 +102,8 @@
   };
   setup(() => {
     element = basicFixture.instantiate();
-    stub('gr-rest-api-interface', {
-      getAccount() { return Promise.resolve(null); },
-    });
-    repoStub = sinon.stub(element.$.restAPI, 'getRepo').returns(
-        Promise.resolve(repoRes));
+    stubRestApi('getAccount').returns(Promise.resolve(null));
+    repoStub = stubRestApi('getRepo').returns(Promise.resolve(repoRes));
     element._loading = false;
     element._ownerOf = [];
     element._canUpload = false;
@@ -118,14 +116,14 @@
   });
 
   test('_repoChanged', done => {
-    const accessStub = sinon.stub(element.$.restAPI,
+    const accessStub = stubRestApi(
         'getRepoAccessRights');
 
     accessStub.withArgs('New Repo').returns(
         Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
     accessStub.withArgs('Another New Repo')
         .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes2))));
-    const capabilitiesStub = sinon.stub(element.$.restAPI,
+    const capabilitiesStub = stubRestApi(
         'getCapabilities');
     capabilitiesStub.returns(Promise.resolve(capabilitiesRes));
 
@@ -160,9 +158,9 @@
         name: 'Access Database',
       },
     };
-    const accessStub = sinon.stub(element.$.restAPI, 'getRepoAccessRights')
+    const accessStub = stubRestApi('getRepoAccessRights')
         .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes2))));
-    const capabilitiesStub = sinon.stub(element.$.restAPI,
+    const capabilitiesStub = stubRestApi(
         'getCapabilities').returns(Promise.resolve(capabilitiesRes));
 
     element._repoChanged().then(() => {
@@ -240,13 +238,11 @@
   test('fires page-error', done => {
     const response = {status: 404};
 
-    sinon.stub(
-        element.$.restAPI, 'getRepoAccessRights')
-        .callsFake((repoName, errFn) => {
-          errFn(response);
-        });
+    stubRestApi('getRepoAccessRights').callsFake((repoName, errFn) => {
+      errFn(response);
+    });
 
-    element.addEventListener('page-error', e => {
+    addListenerForTest(document, 'page-error', e => {
       assert.deepEqual(e.detail.response, response);
       done();
     });
@@ -378,7 +374,7 @@
 
     test('_handleSaveForReview', () => {
       const saveStub =
-          sinon.stub(element.$.restAPI, 'setRepoAccessRightsForReview');
+          stubRestApi('setRepoAccessRightsForReview');
       sinon.stub(element, '_computeAddAndRemove').returns({
         add: {},
         remove: {},
@@ -1161,11 +1157,11 @@
           },
         },
       };
-      sinon.stub(element.$.restAPI, 'getRepoAccessRights').returns(
+      stubRestApi('getRepoAccessRights').returns(
           Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
       sinon.stub(GerritNav, 'navigateToChange');
       let resolver;
-      const saveStub = sinon.stub(element.$.restAPI,
+      const saveStub = stubRestApi(
           'setRepoAccessRights')
           .returns(new Promise(r => resolver = r));
 
@@ -1208,11 +1204,11 @@
           },
         },
       };
-      sinon.stub(element.$.restAPI, 'getRepoAccessRights').returns(
+      stubRestApi('getRepoAccessRights').returns(
           Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
       sinon.stub(GerritNav, 'navigateToChange');
       let resolver;
-      const saveForReviewStub = sinon.stub(element.$.restAPI,
+      const saveForReviewStub = stubRestApi(
           'setRepoAccessRightsForReview')
           .returns(new Promise(r => resolver = r));
 
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..f209729 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
@@ -22,7 +22,6 @@
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-create-change-dialog/gr-create-change-dialog';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
@@ -31,10 +30,6 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {customElement, property} from '@polymer/decorators';
 import {
-  ErrorCallback,
-  RestApiService,
-} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {
   BranchName,
   ConfigInfo,
   PatchSetNum,
@@ -42,6 +37,13 @@
 } from '../../../types/common';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrCreateChangeDialog} from '../gr-create-change-dialog/gr-create-change-dialog';
+import {
+  fireAlert,
+  firePageError,
+  fireTitleChange,
+} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
 
 const GC_MESSAGE = 'Garbage collection completed successfully.';
 const CONFIG_BRANCH = 'refs/meta/config' as BranchName;
@@ -53,7 +55,6 @@
 
 export interface GrRepoCommands {
   $: {
-    restAPI: RestApiService & Element;
     createChangeOverlay: GrOverlay;
     createNewChangeModal: GrCreateChangeDialog;
   };
@@ -90,18 +91,14 @@
   @property({type: Boolean})
   _runningGC = false;
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
   attached() {
     super.attached();
     this._loadRepo();
 
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'Repo Commands'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, 'Repo Commands');
   }
 
   _loadRepo() {
@@ -109,16 +106,10 @@
       // Do not process the error, if the component is not attached to the DOM
       // anymore, which at least in tests can happen.
       if (!this.isConnected) return;
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(response);
     };
 
-    this.$.restAPI.getProjectConfig(this.repo, errFn).then(config => {
+    this.restApiService.getProjectConfig(this.repo, errFn).then(config => {
       if (!config) return;
       // Do not process the response, if the component is not attached to the
       // DOM anymore, which at least in tests can happen.
@@ -137,18 +128,13 @@
   }
 
   _handleRunningGC() {
+    if (!this.repo) return;
     this._runningGC = true;
-    return this.$.restAPI
+    return this.restApiService
       .runRepoGC(this.repo)
       .then(response => {
         if (response?.status === 200) {
-          this.dispatchEvent(
-            new CustomEvent('show-alert', {
-              detail: {message: GC_MESSAGE},
-              bubbles: true,
-              composed: true,
-            })
-          );
+          fireAlert(this, GC_MESSAGE);
         }
       })
       .finally(() => {
@@ -177,7 +163,7 @@
    */
   _handleEditRepoConfig() {
     this._editingConfig = true;
-    return this.$.restAPI
+    return this.restApiService
       .createChange(
         this.repo,
         CONFIG_BRANCH,
@@ -190,13 +176,7 @@
         const message = change
           ? CREATE_CHANGE_SUCCEEDED_MESSAGE
           : CREATE_CHANGE_FAILED_MESSAGE;
-        this.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message},
-            bubbles: true,
-            composed: true,
-          })
-        );
+        fireAlert(this, message);
         if (!change) {
           return;
         }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.ts
index 3880e4a..a77f1ae 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.ts
@@ -28,7 +28,7 @@
       margin-bottom: var(--spacing-xxl);
     }
   </style>
-  <main class="gr-form-styles read-only">
+  <div class="main gr-form-styles read-only">
     <h1 id="Title" class="heading-1">Repository Commands</h1>
     <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
       Loading...
@@ -67,7 +67,7 @@
         </gr-endpoint-decorator>
       </div>
     </div>
-  </main>
+  </div>
   <gr-overlay id="createChangeOverlay" with-backdrop="">
     <gr-dialog
       id="createChangeDialog"
@@ -88,5 +88,4 @@
       </div>
     </gr-dialog>
   </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.js b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.js
index efe4012..893efe55 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-repo-commands.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-repo-commands');
 
@@ -31,8 +32,7 @@
     // Note that this probably does not achieve what it is supposed to, because
     // getProjectConfig() is called as soon as the element is attached, so
     // stubbing it here has not effect anymore.
-    repoStub = sinon.stub(element.$.restAPI, 'getProjectConfig')
-        .returns(Promise.resolve({}));
+    repoStub = stubRestApi('getProjectConfig').returns(Promise.resolve({}));
   });
 
   suite('create new change dialog', () => {
@@ -68,7 +68,7 @@
     let alertStub;
 
     setup(() => {
-      createChangeStub = sinon.stub(element.$.restAPI, 'createChange');
+      createChangeStub = stubRestApi('createChange');
       urlStub = sinon.stub(GerritNav, 'getEditUrlForDiff');
       sinon.stub(GerritNav, 'navigateToRelativeUrl');
       handleSpy = sinon.spy(element, '_handleEditRepoConfig');
@@ -118,12 +118,10 @@
       element.repo = 'test';
 
       const response = {status: 404};
-      sinon.stub(
-          element.$.restAPI, 'getProjectConfig')
-          .callsFake((repo, errFn) => {
-            errFn(response);
-          });
-      element.addEventListener('page-error', e => {
+      stubRestApi('getProjectConfig').callsFake((repo, errFn) => {
+        errFn(response);
+      });
+      addListenerForTest(document, 'page-error', e => {
         assert.deepEqual(e.detail.response, response);
         done();
       });
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
index d9d8560..7b3c7fb 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
@@ -16,7 +16,6 @@
  */
 
 import '../../../styles/shared-styles';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 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';
@@ -24,20 +23,16 @@
 import {htmlTemplate} from './gr-repo-dashboards_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {customElement, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {RepoName, DashboardId, DashboardInfo} from '../../../types/common';
-import {ErrorCallback} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {firePageError} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
 
 interface DashboardRef {
   section: string;
   dashboards: DashboardInfo[];
 }
 
-export interface GrRepoDashboards {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
 @customElement('gr-repo-dashboards')
 export class GrRepoDashboards extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
@@ -55,6 +50,8 @@
   @property({type: Array})
   _dashboards?: DashboardRef[];
 
+  private readonly restApiService = appContext.restApiService;
+
   _repoChanged(repo?: RepoName) {
     this._loading = true;
     if (!repo) {
@@ -62,16 +59,10 @@
     }
 
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(response);
     };
 
-    return this.$.restAPI
+    return this.restApiService
       .getRepoDashboards(repo, errFn)
       .then((res?: DashboardInfo[]) => {
         if (!res) {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_html.ts
index 7cdd10e..51ea417 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_html.ts
@@ -67,5 +67,4 @@
       </template>
     </tbody>
   </table>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.js b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.js
index b4d3575..829611d 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-repo-dashboards.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-repo-dashboards');
 
@@ -30,7 +31,7 @@
 
   suite('dashboard table', () => {
     setup(() => {
-      sinon.stub(element.$.restAPI, 'getRepoDashboards').returns(
+      stubRestApi('getRepoDashboards').returns(
           Promise.resolve([
             {
               id: 'default:contributor',
@@ -123,13 +124,11 @@
   suite('404', () => {
     test('fires page-error', done => {
       const response = {status: 404};
-      sinon.stub(
-          element.$.restAPI, 'getRepoDashboards')
-          .callsFake((repo, errFn) => {
-            errFn(response);
-          });
+      stubRestApi('getRepoDashboards').callsFake((repo, errFn) => {
+        errFn(response);
+      });
 
-      element.addEventListener('page-error', e => {
+      addListenerForTest(document, 'page-error', e => {
         assert.deepEqual(e.detail.response, response);
         done();
       });
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
index 2fce6e1..a486e27 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
@@ -25,7 +25,6 @@
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-list-view/gr-list-view';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-create-pointer-dialog/gr-create-pointer-dialog';
 import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
@@ -36,10 +35,6 @@
 import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
 import {encodeURL} from '../../../utils/url-util';
 import {customElement, property} from '@polymer/decorators';
-import {
-  ErrorCallback,
-  RestApiService,
-} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrCreatePointerDialog} from '../gr-create-pointer-dialog/gr-create-pointer-dialog';
 import {
@@ -53,12 +48,14 @@
 import {AppElementRepoParams} from '../../gr-app-types';
 import {PolymerDomRepeatEvent} from '../../../types/types';
 import {RepoDetailView} from '../../core/gr-navigation/gr-navigation';
+import {firePageError} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
 
 const PGP_START = '-----BEGIN PGP SIGNATURE-----';
 
 export interface GrRepoDetailList {
   $: {
-    restAPI: RestApiService & Element;
     overlay: GrOverlay;
     createOverlay: GrOverlay;
     createNewModal: GrCreatePointerDialog;
@@ -120,8 +117,10 @@
   @property({type: String})
   _revisedRef?: GitRef;
 
+  private readonly restApiService = appContext.restApiService;
+
   _determineIfOwner(repo: RepoName) {
-    return this.$.restAPI
+    return this.restApiService
       .getRepoAccess(repo)
       .then(access => (this._isOwner = !!access && !!access[repo].is_owner));
   }
@@ -182,16 +181,11 @@
     this._items = [];
     flush();
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(response);
     };
+
     if (detailType === RepoDetailView.BRANCHES) {
-      return this.$.restAPI
+      return this.restApiService
         .getRepoBranches(filter, repo, itemsPerPage, offset, errFn)
         .then(items => {
           if (!items) {
@@ -201,7 +195,7 @@
           this._loading = false;
         });
     } else if (detailType === RepoDetailView.TAGS) {
-      return this.$.restAPI
+      return this.restApiService
         .getRepoTags(filter, repo, itemsPerPage, offset, errFn)
         .then(items => {
           if (!items) {
@@ -249,7 +243,7 @@
   }
 
   _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
+    return this.restApiService.getLoggedIn();
   }
 
   _computeEditingClass(isEditing: boolean) {
@@ -277,7 +271,7 @@
   }
 
   _setRepoHead(repo: RepoName, ref: GitRef, e: PolymerDomRepeatEvent<GitRef>) {
-    return this.$.restAPI.setRepoHead(repo, ref).then(res => {
+    return this.restApiService.setRepoHead(repo, ref).then(res => {
       if (res.status < 400) {
         this._isEditing = false;
         e.model.set('item.revision', ref);
@@ -309,7 +303,7 @@
       return Promise.reject(new Error('undefined repo or refName'));
     }
     if (this.detailType === RepoDetailView.BRANCHES) {
-      return this.$.restAPI
+      return this.restApiService
         .deleteRepoBranches(this._repo, this._refName)
         .then(itemDeleted => {
           if (itemDeleted.status === 204) {
@@ -323,7 +317,7 @@
           }
         });
     } else if (this.detailType === RepoDetailView.TAGS) {
-      return this.$.restAPI
+      return this.restApiService
         .deleteRepoTags(this._repo, this._refName)
         .then(itemDeleted => {
           if (itemDeleted.status === 204) {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts
index 196797f..4435d3a 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts
@@ -220,5 +220,4 @@
       </div>
     </gr-dialog>
   </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js
index 7727821..990ea4b 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js
@@ -20,6 +20,7 @@
 import 'lodash/lodash.js';
 import {page} from '../../../utils/page-wrapper-utils.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-repo-detail-list');
 
@@ -74,18 +75,12 @@
           ref: 'HEAD',
           revision: 'master',
         }].concat(_.times(25, branchGenerator));
-
-        stub('gr-rest-api-interface', {
-          getRepoBranches(num, project, offset) {
-            return Promise.resolve(branches);
-          },
-        });
+        stubRestApi('getRepoBranches').returns(Promise.resolve(branches));
 
         const params = {
           repo: 'test',
           detail: 'branches',
         };
-
         element._paramsChanged(params).then(() => { flush(done); });
       });
 
@@ -118,7 +113,7 @@
 
       test('Edit HEAD button not admin', done => {
         sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-        sinon.stub(element.$.restAPI, 'getRepoAccess').returns(
+        stubRestApi('getRepoAccess').returns(
             Promise.resolve({
               test: {is_owner: false},
             }));
@@ -142,7 +137,7 @@
             .querySelector('.revisionWithEditing');
 
         sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-        sinon.stub(element.$.restAPI, 'getRepoAccess').returns(
+        stubRestApi('getRepoAccess').returns(
             Promise.resolve({
               test: {is_owner: true},
             }));
@@ -219,7 +214,7 @@
       test('_handleSaveRevision with invalid rev', done => {
         const event = {model: {set: sinon.stub()}};
         element._isEditing = true;
-        sinon.stub(element.$.restAPI, 'setRepoHead').returns(
+        stubRestApi('setRepoHead').returns(
             Promise.resolve({
               status: 400,
             })
@@ -235,7 +230,7 @@
       test('_handleSaveRevision with valid rev', done => {
         const event = {model: {set: sinon.stub()}};
         element._isEditing = true;
-        sinon.stub(element.$.restAPI, 'setRepoHead').returns(
+        stubRestApi('setRepoHead').returns(
             Promise.resolve({
               status: 200,
             })
@@ -257,12 +252,7 @@
     suite('list with less then 25 branches', () => {
       setup(done => {
         branches = _.times(25, branchGenerator);
-
-        stub('gr-rest-api-interface', {
-          getRepoBranches(num, repo, offset) {
-            return Promise.resolve(branches);
-          },
-        });
+        stubRestApi('getRepoBranches').returns(Promise.resolve(branches));
 
         const params = {
           repo: 'test',
@@ -278,40 +268,32 @@
     });
 
     suite('filter', () => {
-      test('_paramsChanged', done => {
-        sinon.stub(
-            element.$.restAPI,
-            'getRepoBranches')
-            .callsFake(() => Promise.resolve(branches));
+      test('_paramsChanged', async () => {
+        const stub = stubRestApi('getRepoBranches').returns(
+            Promise.resolve(branches));
         const params = {
           detail: 'branches',
           repo: 'test',
           filter: 'test',
           offset: 25,
         };
-        element._paramsChanged(params).then(() => {
-          assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[0],
-              'test');
-          assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[1],
-              'test');
-          assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[2],
-              25);
-          assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[3],
-              25);
-          done();
-        });
+        await element._paramsChanged(params);
+        assert.equal(stub.lastCall.args[0], 'test');
+        assert.equal(stub.lastCall.args[1], 'test');
+        assert.equal(stub.lastCall.args[2], 25);
+        assert.equal(stub.lastCall.args[3], 25);
       });
     });
 
     suite('404', () => {
       test('fires page-error', done => {
         const response = {status: 404};
-        sinon.stub(element.$.restAPI, 'getRepoBranches').callsFake(
+        stubRestApi('getRepoBranches').callsFake(
             (filter, repo, reposBranchesPerPage, opt_offset, errFn) => {
               errFn(response);
             });
 
-        element.addEventListener('page-error', e => {
+        addListenerForTest(document, 'page-error', e => {
           assert.deepEqual(e.detail.response, response);
           done();
         });
@@ -360,12 +342,7 @@
     suite('list of repo tags', () => {
       setup(done => {
         tags = _.times(26, tagGenerator);
-
-        stub('gr-rest-api-interface', {
-          getRepoTags(num, repo, offset) {
-            return Promise.resolve(tags);
-          },
-        });
+        stubRestApi('getRepoTags').returns(Promise.resolve(tags));
 
         const params = {
           repo: 'test',
@@ -435,12 +412,7 @@
     suite('list with less then 25 tags', () => {
       setup(done => {
         tags = _.times(25, tagGenerator);
-
-        stub('gr-rest-api-interface', {
-          getRepoTags(num, project, offset) {
-            return Promise.resolve(tags);
-          },
-        });
+        stubRestApi('getRepoTags').returns(Promise.resolve(tags));
 
         const params = {
           repo: 'test',
@@ -456,28 +428,19 @@
     });
 
     suite('filter', () => {
-      test('_paramsChanged', done => {
-        sinon.stub(
-            element.$.restAPI,
-            'getRepoTags')
-            .callsFake(() => Promise.resolve(tags));
+      test('_paramsChanged', async () => {
+        const stub = stubRestApi('getRepoTags').returns(Promise.resolve(tags));
         const params = {
           repo: 'test',
           detail: 'tags',
           filter: 'test',
           offset: 25,
         };
-        element._paramsChanged(params).then(() => {
-          assert.equal(element.$.restAPI.getRepoTags.lastCall.args[0],
-              'test');
-          assert.equal(element.$.restAPI.getRepoTags.lastCall.args[1],
-              'test');
-          assert.equal(element.$.restAPI.getRepoTags.lastCall.args[2],
-              25);
-          assert.equal(element.$.restAPI.getRepoTags.lastCall.args[3],
-              25);
-          done();
-        });
+        await element._paramsChanged(params);
+        assert.equal(stub.lastCall.args[0], 'test');
+        assert.equal(stub.lastCall.args[1], 'test');
+        assert.equal(stub.lastCall.args[2], 25);
+        assert.equal(stub.lastCall.args[3], 25);
       });
     });
 
@@ -520,12 +483,12 @@
     suite('404', () => {
       test('fires page-error', done => {
         const response = {status: 404};
-        sinon.stub(element.$.restAPI, 'getRepoTags').callsFake(
+        stubRestApi('getRepoTags').callsFake(
             (filter, repo, reposTagsPerPage, opt_offset, errFn) => {
               errFn(response);
             });
 
-        element.addEventListener('page-error', e => {
+        addListenerForTest(document, 'page-error', e => {
           assert.deepEqual(e.detail.response, response);
           done();
         });
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
index ba2d850..d6aa0e6 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
@@ -19,7 +19,6 @@
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-list-view/gr-list-view';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-create-repo-dialog/gr-create-repo-dialog';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
@@ -29,11 +28,12 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {customElement, property, observe, computed} from '@polymer/decorators';
 import {AppElementAdminParams} from '../../gr-app-types';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {RepoName, ProjectInfoWithName} from '../../../types/common';
 import {GrCreateRepoDialog} from '../gr-create-repo-dialog/gr-create-repo-dialog';
 import {ProjectState} from '../../../constants/constants';
+import {fireTitleChange} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -43,7 +43,6 @@
 
 export interface GrRepoList {
   $: {
-    restAPI: RestApiService & Element;
     createOverlay: GrOverlay;
     createNewModal: GrCreateRepoDialog;
   };
@@ -89,17 +88,13 @@
     return this.computeShownItems(this._repos);
   }
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
   attached() {
     super.attached();
     this._getCreateRepoCapability();
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'Repos'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, 'Repos');
     this._maybeOpenCreateOverlay(this.params);
   }
 
@@ -130,11 +125,11 @@
   }
 
   _getCreateRepoCapability() {
-    return this.$.restAPI.getAccount().then(account => {
+    return this.restApiService.getAccount().then(account => {
       if (!account) {
         return;
       }
-      return this.$.restAPI
+      return this.restApiService
         .getAccountCapabilities(['createProject'])
         .then(capabilities => {
           if (capabilities?.createProject) {
@@ -146,18 +141,20 @@
 
   _getRepos(filter: string, reposPerPage: number, offset?: number) {
     this._repos = [];
-    return this.$.restAPI.getRepos(filter, reposPerPage, offset).then(repos => {
-      // Late response.
-      if (filter !== this._filter || !repos) {
-        return;
-      }
-      this._repos = repos;
-      this._loading = false;
-    });
+    return this.restApiService
+      .getRepos(filter, reposPerPage, offset)
+      .then(repos => {
+        // Late response.
+        if (filter !== this._filter || !repos) {
+          return;
+        }
+        this._repos = repos;
+        this._loading = false;
+      });
   }
 
   _refreshReposList() {
-    this.$.restAPI.invalidateReposCache();
+    this.restApiService.invalidateReposCache();
     return this._getRepos(this._filter, this._reposPerPage, this._offset);
   }
 
@@ -172,7 +169,9 @@
   }
 
   _handleCreateClicked() {
-    this.$.createOverlay.open();
+    this.$.createOverlay.open().then(() => {
+      this.$.createNewModal.focus();
+    });
   }
 
   _readOnly(repo: ProjectInfoWithName) {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.ts
index 4889845..20c72f9 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.ts
@@ -115,5 +115,4 @@
       </div>
     </gr-dialog>
   </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
index e2a29f2..4904bf4 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
@@ -19,6 +19,7 @@
 import './gr-repo-list.js';
 import {page} from '../../../utils/page-wrapper-utils.js';
 import 'lodash/lodash.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-repo-list');
 
@@ -51,11 +52,7 @@
   suite('list with repos', () => {
     setup(done => {
       repos = _.times(26, repoGenerator);
-      stub('gr-rest-api-interface', {
-        getRepos(num, offset) {
-          return Promise.resolve(repos);
-        },
-      });
+      stubRestApi('getRepos').returns(Promise.resolve(repos));
       element._paramsChanged(value).then(() => { flush(done); });
     });
 
@@ -86,13 +83,7 @@
   suite('list with less then 25 repos', () => {
     setup(done => {
       repos = _.times(25, repoGenerator);
-
-      stub('gr-rest-api-interface', {
-        getRepos(num, offset) {
-          return Promise.resolve(repos);
-        },
-      });
-
+      stubRestApi('getRepos').returns(Promise.resolve(repos));
       element._paramsChanged(value).then(() => { flush(done); });
     });
 
@@ -108,22 +99,19 @@
       reposFiltered = _.times(1, repoGenerator);
     });
 
-    test('_paramsChanged', done => {
-      sinon.stub(element.$.restAPI, 'getRepos')
-          .callsFake( () => Promise.resolve(repos));
+    test('_paramsChanged', async () => {
+      const repoStub = stubRestApi('getRepos');
+      repoStub.returns(Promise.resolve(repos));
       const value = {
         filter: 'test',
         offset: 25,
       };
-      element._paramsChanged(value).then(() => {
-        assert.isTrue(element.$.restAPI.getRepos.lastCall
-            .calledWithExactly('test', 25, 25));
-        done();
-      });
+      await element._paramsChanged(value);
+      assert.isTrue(repoStub.lastCall.calledWithExactly('test', 25, 25));
     });
 
     test('latest repos requested are always set', done => {
-      const repoStub = sinon.stub(element.$.restAPI, 'getRepos');
+      const repoStub = stubRestApi('getRepos');
       repoStub.withArgs('test').returns(Promise.resolve(repos));
       repoStub.withArgs('filter').returns(Promise.resolve(reposFiltered));
       element._filter = 'test';
@@ -163,7 +151,8 @@
     });
 
     test('_handleCreateClicked opens modal', () => {
-      const openStub = sinon.stub(element.$.createOverlay, 'open');
+      const openStub = sinon.stub(element.$.createOverlay, 'open').returns(
+          Promise.resolve());
       element._handleCreateClicked();
       assert.isTrue(openStub.called);
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
index 101c77a..b6881ff 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -19,7 +19,6 @@
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../shared/gr-download-commands/gr-download-commands';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-select/gr-select';
 import '../../../styles/gr-form-styles';
 import '../../../styles/gr-subpage-styles';
@@ -32,10 +31,6 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {customElement, property, observe} from '@polymer/decorators';
 import {
-  RestApiService,
-  ErrorCallback,
-} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {
   ConfigInfo,
   RepoName,
   InheritedBooleanInfo,
@@ -48,6 +43,10 @@
 import {ProjectState} from '../../../constants/constants';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {hasOwnProperty} from '../../../utils/common-util';
+import {firePageError, fireTitleChange} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {WebLinkInfo} from '../../../types/diff';
+import {ErrorCallback} from '../../../api/rest';
 
 const STATES = {
   active: {value: ProjectState.ACTIVE, label: 'Active'},
@@ -83,11 +82,6 @@
   },
 };
 
-export interface GrRepo {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
 @customElement('gr-repo')
 export class GrRepo extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
@@ -144,18 +138,17 @@
   @property({type: Object})
   _schemesObj?: SchemesInfoMap;
 
+  @property({type: Array})
+  weblinks: WebLinkInfo[] = [];
+
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
   attached() {
     super.attached();
     this._loadRepo();
 
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: this.repo},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, `${this.repo}`);
   }
 
   _computePluginData(
@@ -182,13 +175,7 @@
     const promises = [];
 
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(response);
     };
 
     promises.push(
@@ -197,7 +184,11 @@
         if (loggedIn) {
           const repo = this.repo;
           if (!repo) throw new Error('undefined repo');
-          this.$.restAPI.getRepoAccess(repo).then(access => {
+          this.restApiService.getRepo(repo).then(repo => {
+            if (!repo?.web_links) return;
+            this.weblinks = repo.web_links;
+          });
+          this.restApiService.getRepoAccess(repo).then(access => {
             if (!access || this.repo !== repo) {
               return;
             }
@@ -210,7 +201,7 @@
     );
 
     promises.push(
-      this.$.restAPI.getProjectConfig(this.repo, errFn).then(config => {
+      this.restApiService.getProjectConfig(this.repo, errFn).then(config => {
         if (!config) {
           return;
         }
@@ -232,7 +223,7 @@
     );
 
     promises.push(
-      this.$.restAPI.getConfig().then(config => {
+      this.restApiService.getConfig().then(config => {
         if (!config) {
           return;
         }
@@ -256,7 +247,7 @@
     if (!_loggedIn) {
       return;
     }
-    this.$.restAPI.getPreferences().then(prefs => {
+    this.restApiService.getPreferences().then(prefs => {
       if (prefs?.download_scheme) {
         // Note (issue 5180): normalize the download scheme with lower-case.
         this._selectedScheme = prefs.download_scheme.toLowerCase();
@@ -324,7 +315,7 @@
   }
 
   _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
+    return this.restApiService.getLoggedIn();
   }
 
   _formatRepoConfigForSave(repoConfig: ConfigInfo): ConfigInput {
@@ -340,12 +331,14 @@
       if (key === 'plugin_config') {
         configInputObj.plugin_config_values = repoConfig.plugin_config;
       } else if (typeof repoConfig[key] === 'object') {
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
         const repoConfigObj: any = repoConfig[key];
         if (repoConfigObj.configured_value) {
           configInputObj[key as keyof ConfigInput] =
             repoConfigObj.configured_value;
         }
       } else {
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
         configInputObj[key as keyof ConfigInput] = repoConfig[key] as any;
       }
     }
@@ -355,7 +348,7 @@
   _handleSaveRepoConfig() {
     if (!this._repoConfig || !this.repo)
       return Promise.reject(new Error('undefined repoConfig or repo'));
-    return this.$.restAPI
+    return this.restApiService
       .saveRepoConfig(
         this.repo,
         this._formatRepoConfigForSave(this._repoConfig)
@@ -399,21 +392,14 @@
     schemesObj?: SchemesInfoMap,
     _selectedScheme?: string
   ) {
-    if (!schemesObj || !repo || !_selectedScheme) {
-      return [];
-    }
+    if (!schemesObj || !repo || !_selectedScheme) return [];
+    if (!hasOwnProperty(schemesObj, _selectedScheme)) return [];
+    const commandObj = schemesObj[_selectedScheme].clone_commands;
     const commands = [];
-    let commandObj: {[title: string]: string} = {};
-    if (hasOwnProperty(schemesObj, _selectedScheme)) {
-      commandObj = schemesObj[_selectedScheme].clone_commands;
-    }
-    for (const title in commandObj) {
-      if (!hasOwnProperty(commandObj, title)) {
-        continue;
-      }
+    for (const [title, command] of Object.entries(commandObj)) {
       commands.push({
         title,
-        command: commandObj[title]
+        command: command
           .replace(/\${project}/gi, encodeURI(repo))
           .replace(
             /\${project-base-name}/gi,
@@ -432,6 +418,10 @@
     return GerritNav.getUrlForProjectChanges(name);
   }
 
+  _computeBrowseUrl(weblinks: WebLinkInfo[]) {
+    return weblinks?.[0]?.url;
+  }
+
   _handlePluginConfigChanged({
     detail: {name, config, notifyPath},
   }: {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts
index 2e86758..1ca5636 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts
@@ -45,7 +45,7 @@
   <style include="gr-form-styles">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
   </style>
-  <main class="gr-form-styles read-only">
+  <div class="main gr-form-styles read-only">
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
@@ -55,7 +55,13 @@
       </h1>
       <hr />
       <div>
-        <a href$="[[_computeChangesUrl(repo)]]">(view changes)</a>
+        <a href$="[[_computeBrowseUrl(weblinks)]]"
+          ><gr-button link disabled="[[!_computeBrowseUrl(weblinks)]]"
+            >Browse</gr-button
+          ></a
+        ><a href$="[[_computeChangesUrl(repo)]]"
+          ><gr-button link>View Changes</gr-button></a
+        >
       </div>
     </div>
     <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
@@ -435,6 +441,5 @@
         </gr-endpoint-decorator>
       </div>
     </div>
-  </main>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </div>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.js b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.js
index 93a9d64..c299b55 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.js
@@ -18,12 +18,13 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-repo.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-repo');
 
 suite('gr-repo tests', () => {
   let element;
-
+  let loggedInStub;
   let repoStub;
   const repoConf = {
     description: 'Access inherited by all other projects.',
@@ -98,17 +99,11 @@
   }
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(false); },
-      getConfig() {
-        return Promise.resolve({download: {}});
-      },
-    });
+    loggedInStub = stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    stubRestApi('getConfig').returns(Promise.resolve({download: {}}));
+    repoStub =
+        stubRestApi('getProjectConfig').returns(Promise.resolve(repoConf));
     element = basicFixture.instantiate();
-    repoStub = sinon.stub(
-        element.$.restAPI,
-        'getProjectConfig')
-        .callsFake(() => Promise.resolve(repoConf));
   });
 
   test('_computePluginData', () => {
@@ -159,37 +154,28 @@
     assert.isTrue(element._readOnly);
   });
 
-  test('form defaults to read only when not logged in', done => {
+  test('form defaults to read only when not logged in', async () => {
     element.repo = REPO;
-    element._loadRepo().then(() => {
-      assert.isTrue(element._readOnly);
-      done();
-    });
+    await element._loadRepo();
+    assert.isTrue(element._readOnly);
   });
 
-  test('form defaults to read only when logged in and not admin', done => {
+  test('form defaults to read only when logged in and not admin', async () => {
     element.repo = REPO;
-    sinon.stub(element, '_getLoggedIn').callsFake(() => Promise.resolve(true));
-    sinon.stub(
-        element.$.restAPI,
-        'getRepoAccess')
+    stubRestApi('getRepoAccess')
         .callsFake(() => Promise.resolve({'test-repo': {}}));
-    element._loadRepo().then(() => {
-      assert.isTrue(element._readOnly);
-      done();
-    });
+    await element._loadRepo();
+    assert.isTrue(element._readOnly);
   });
 
-  test('all form elements are disabled when not admin', done => {
+  test('all form elements are disabled when not admin', async () => {
     element.repo = REPO;
-    element._loadRepo().then(() => {
-      flush();
-      const formFields = getFormFields();
-      for (const field of formFields) {
-        assert.isTrue(field.hasAttribute('disabled'));
-      }
-      done();
-    });
+    await element._loadRepo();
+    flush();
+    const formFields = getFormFields();
+    for (const field of formFields) {
+      assert.isTrue(field.hasAttribute('disabled'));
+    }
   });
 
   test('_formatBooleanSelect', () => {
@@ -246,11 +232,10 @@
     element.repo = 'test';
 
     const response = {status: 404};
-    sinon.stub(
-        element.$.restAPI, 'getProjectConfig').callsFake((repo, errFn) => {
+    stubRestApi('getProjectConfig').callsFake((repo, errFn) => {
       errFn(response);
     });
-    element.addEventListener('page-error', e => {
+    addListenerForTest(document, 'page-error', e => {
       assert.deepEqual(e.detail.response, response);
       done();
     });
@@ -261,48 +246,38 @@
   suite('admin', () => {
     setup(() => {
       element.repo = REPO;
-      sinon.stub(element, '_getLoggedIn')
-          .callsFake(() => Promise.resolve(true));
-      sinon.stub(
-          element.$.restAPI,
-          'getRepoAccess')
-          .callsFake(() => Promise.resolve({'test-repo': {is_owner: true}}));
+      loggedInStub.returns(Promise.resolve(true));
+      stubRestApi('getRepoAccess')
+          .returns(Promise.resolve({'test-repo': {is_owner: true}}));
     });
 
-    test('all form elements are enabled', done => {
-      element._loadRepo().then(() => {
-        flush();
-        const formFields = getFormFields();
-        for (const field of formFields) {
-          assert.isFalse(field.hasAttribute('disabled'));
-        }
-        assert.isFalse(element._loading);
-        done();
-      });
+    test('all form elements are enabled', async () => {
+      await element._loadRepo();
+      await flush();
+      const formFields = getFormFields();
+      for (const field of formFields) {
+        assert.isFalse(field.hasAttribute('disabled'));
+      }
+      assert.isFalse(element._loading);
     });
 
-    test('state gets set correctly', done => {
-      element._loadRepo().then(() => {
-        assert.equal(element._repoConfig.state, 'ACTIVE');
-        assert.equal(element.$.stateSelect.bindValue, 'ACTIVE');
-        done();
-      });
+    test('state gets set correctly', async () => {
+      await element._loadRepo();
+      assert.equal(element._repoConfig.state, 'ACTIVE');
+      assert.equal(element.$.stateSelect.bindValue, 'ACTIVE');
     });
 
-    test('inherited submit type value is calculated correctly', done => {
-      element
-          ._loadRepo().then(() => {
-            const sel = element.$.submitTypeSelect;
-            assert.equal(sel.bindValue, 'INHERIT');
-            assert.equal(
-                sel.nativeSelect.options[0].text,
-                'Inherit (Merge if necessary)'
-            );
-            done();
-          });
+    test('inherited submit type value is calculated correctly', async () => {
+      await element._loadRepo();
+      const sel = element.$.submitTypeSelect;
+      assert.equal(sel.bindValue, 'INHERIT');
+      assert.equal(
+          sel.nativeSelect.options[0].text,
+          'Inherit (Merge if necessary)'
+      );
     });
 
-    test('fields update and save correctly', () => {
+    test('fields update and save correctly', async () => {
       const configInputObj = {
         description: 'new description',
         use_contributor_agreements: 'TRUE',
@@ -322,59 +297,57 @@
         enable_reviewer_by_email: 'TRUE',
       };
 
-      const saveStub = sinon.stub(element.$.restAPI, 'saveRepoConfig')
+      const saveStub = stubRestApi('saveRepoConfig')
           .callsFake(() => Promise.resolve({}));
 
-      const button = element.root.querySelector('gr-button');
+      const button = element.root.querySelectorAll('gr-button')[2];
 
-      return element._loadRepo().then(() => {
-        assert.isTrue(button.hasAttribute('disabled'));
-        assert.isFalse(element.$.Title.classList.contains('edited'));
-        element.$.descriptionInput.bindValue = configInputObj.description;
-        element.$.stateSelect.bindValue = configInputObj.state;
-        element.$.submitTypeSelect.bindValue = configInputObj.submit_type;
-        element.$.contentMergeSelect.bindValue =
-            configInputObj.use_content_merge;
-        element.$.newChangeSelect.bindValue =
-            configInputObj.create_new_change_for_all_not_in_target;
-        element.$.requireChangeIdSelect.bindValue =
-            configInputObj.require_change_id;
-        element.$.enableSignedPush.bindValue =
-            configInputObj.enable_signed_push;
-        element.$.requireSignedPush.bindValue =
-            configInputObj.require_signed_push;
-        element.$.rejectImplicitMergesSelect.bindValue =
-            configInputObj.reject_implicit_merges;
-        element.$.setAllnewChangesPrivateByDefaultSelect.bindValue =
-            configInputObj.private_by_default;
-        element.$.matchAuthoredDateWithCommitterDateSelect.bindValue =
-            configInputObj.match_author_to_committer_date;
-        const inputElement = PolymerElement ?
-          element.$.maxGitObjSizeIronInput : element.$.maxGitObjSizeInput;
-        inputElement.bindValue = configInputObj.max_object_size_limit;
-        element.$.contributorAgreementSelect.bindValue =
-            configInputObj.use_contributor_agreements;
-        element.$.useSignedOffBySelect.bindValue =
-            configInputObj.use_signed_off_by;
-        element.$.rejectEmptyCommitSelect.bindValue =
-            configInputObj.reject_empty_commit;
-        element.$.unRegisteredCcSelect.bindValue =
-            configInputObj.enable_reviewer_by_email;
+      await element._loadRepo();
+      assert.isTrue(button.hasAttribute('disabled'));
+      assert.isFalse(element.$.Title.classList.contains('edited'));
+      element.$.descriptionInput.bindValue = configInputObj.description;
+      element.$.stateSelect.bindValue = configInputObj.state;
+      element.$.submitTypeSelect.bindValue = configInputObj.submit_type;
+      element.$.contentMergeSelect.bindValue =
+          configInputObj.use_content_merge;
+      element.$.newChangeSelect.bindValue =
+          configInputObj.create_new_change_for_all_not_in_target;
+      element.$.requireChangeIdSelect.bindValue =
+          configInputObj.require_change_id;
+      element.$.enableSignedPush.bindValue =
+          configInputObj.enable_signed_push;
+      element.$.requireSignedPush.bindValue =
+          configInputObj.require_signed_push;
+      element.$.rejectImplicitMergesSelect.bindValue =
+          configInputObj.reject_implicit_merges;
+      element.$.setAllnewChangesPrivateByDefaultSelect.bindValue =
+          configInputObj.private_by_default;
+      element.$.matchAuthoredDateWithCommitterDateSelect.bindValue =
+          configInputObj.match_author_to_committer_date;
+      const inputElement = PolymerElement ?
+        element.$.maxGitObjSizeIronInput : element.$.maxGitObjSizeInput;
+      inputElement.bindValue = configInputObj.max_object_size_limit;
+      element.$.contributorAgreementSelect.bindValue =
+          configInputObj.use_contributor_agreements;
+      element.$.useSignedOffBySelect.bindValue =
+          configInputObj.use_signed_off_by;
+      element.$.rejectEmptyCommitSelect.bindValue =
+          configInputObj.reject_empty_commit;
+      element.$.unRegisteredCcSelect.bindValue =
+          configInputObj.enable_reviewer_by_email;
 
-        assert.isFalse(button.hasAttribute('disabled'));
-        assert.isTrue(element.$.configurations.classList.contains('edited'));
+      assert.isFalse(button.hasAttribute('disabled'));
+      assert.isTrue(element.$.configurations.classList.contains('edited'));
 
-        const formattedObj =
-            element._formatRepoConfigForSave(element._repoConfig);
-        assert.deepEqual(formattedObj, configInputObj);
+      const formattedObj =
+          element._formatRepoConfigForSave(element._repoConfig);
+      assert.deepEqual(formattedObj, configInputObj);
 
-        return element._handleSaveRepoConfig().then(() => {
-          assert.isTrue(button.hasAttribute('disabled'));
-          assert.isFalse(element.$.Title.classList.contains('edited'));
-          assert.isTrue(saveStub.lastCall.calledWithExactly(REPO,
-              configInputObj));
-        });
-      });
+      await element._handleSaveRepoConfig();
+      assert.isTrue(button.hasAttribute('disabled'));
+      assert.isFalse(element.$.Title.classList.contains('edited'));
+      assert.isTrue(saveStub.lastCall.calledWithExactly(REPO,
+          configInputObj));
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
index 8843933..13d0e50 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
@@ -19,7 +19,6 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-select/gr-select';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
@@ -27,6 +26,7 @@
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {AccessPermissionId} from '../../../utils/access-util';
 import {property, customElement, observe} from '@polymer/decorators';
+import {fireEvent} from '../../../utils/event-util';
 
 /**
  * Fired when the rule has been modified or removed.
@@ -268,15 +268,11 @@
   _handleRemoveRule() {
     if (!this.rule) return;
     if (this.rule.value.added) {
-      this.dispatchEvent(
-        new CustomEvent('added-rule-removed', {bubbles: true, composed: true})
-      );
+      fireEvent(this, 'added-rule-removed');
     }
     this._deleted = true;
     this.rule.value.deleted = true;
-    this.dispatchEvent(
-      new CustomEvent('access-modified', {bubbles: true, composed: true})
-    );
+    fireEvent(this, 'access-modified');
   }
 
   _handleUndoRemove() {
@@ -305,9 +301,7 @@
     }
     this.rule.value.modified = true;
     // Allows overall access page to know a change has been made.
-    this.dispatchEvent(
-      new CustomEvent('access-modified', {bubbles: true, composed: true})
-    );
+    fireEvent(this, 'access-modified');
   }
 
   _setOriginalRuleValues(value: RuleValue) {
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.ts
index 98403e0..c4d7688 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.ts
@@ -156,5 +156,4 @@
       >Undo</gr-button
     >
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
index b0065d9..9cc6357 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
@@ -192,7 +192,7 @@
       };
       element.section = 'refs/*';
 
-      // Typically called on ready since elements will have properies defined
+      // Typically called on ready since elements will have properties defined
       // by the parent element.
       element._setupValues(element.rule);
       flush();
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..558037d 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
@@ -49,6 +49,7 @@
   Timestamp,
 } from '../../../types/common';
 import {hasOwnProperty} from '../../../utils/common-util';
+import {pluralize} from '../../../utils/string-util';
 
 enum ChangeSize {
   XS = 10,
@@ -152,15 +153,18 @@
     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(pluralize(num, 'unresolved comment'));
     }
     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_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
index fdb4534..f1e75e4 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
@@ -54,7 +54,7 @@
       white-space: nowrap;
     }
     .reviewers {
-      --account-max-length: 90px;
+      --account-max-length: 70px;
     }
     .spacer {
       height: 0;
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..acf71c3 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
@@ -19,6 +19,7 @@
 import './gr-change-list-item.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {LabelCategory} from './gr-change-list-item.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-change-list-item');
 
@@ -26,10 +27,8 @@
   let element;
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-      getLoggedIn() { return Promise.resolve(false); },
-    });
+    stubRestApi('getConfig').returns(Promise.resolve({}));
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
     element = basicFixture.instantiate();
   });
 
@@ -88,35 +87,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 +124,12 @@
     '1 unresolved comment');
     assert.equal(element._computeLabelTitle(
         {
+          labels: {'Code-Review': {approved: {name: 'Diffy'}, value: 1}},
+          unresolved_comment_count: 1,
+        }, 'Code-Review'),
+    '1 unresolved comment,\nCode-Review by Diffy');
+    assert.equal(element._computeLabelTitle(
+        {
           labels: {'Code-Review': {approved: true, value: 1}},
           unresolved_comment_count: 2,
         }, 'Code-Review'),
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
index 927d32f..42741fa 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
@@ -16,7 +16,6 @@
  */
 
 import '../../shared/gr-icons/gr-icons';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-change-list/gr-change-list';
 import '../gr-repo-header/gr-repo-header';
 import '../gr-user-header/gr-user-header';
@@ -26,7 +25,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-list-view_html';
 import {page} from '../../../utils/page-wrapper-utils';
-import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {customElement, property} from '@polymer/decorators';
 import {AppElementParams} from '../../gr-app-types';
 import {
@@ -36,17 +35,18 @@
   EmailAddress,
   PreferencesInput,
 } from '../../../types/common';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {ChangeListToggleReviewedDetail} from '../gr-change-list-item/gr-change-list-item';
 import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {ChangeListViewState} from '../../../types/types';
+import {fireTitleChange} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {GerritView} from '../../../services/router/router-model';
 
-const LookupQueryPatterns = {
-  CHANGE_ID: /^\s*i?[0-9a-f]{7,40}\s*$/i,
-  CHANGE_NUM: /^\s*[1-9][0-9]*\s*$/g,
-  COMMIT: /[0-9a-f]{40}/,
-};
+const LOOKUP_QUERY_PATTERNS: RegExp[] = [
+  /^\s*i?[0-9a-f]{7,40}\s*$/i, // CHANGE_ID
+  /^\s*[1-9][0-9]*\s*$/g, // CHANGE_NUM
+  /[0-9a-f]{40}/, // COMMIT
+];
 
 const USER_QUERY_PATTERN = /^owner:\s?("[^"]+"|[^ ]+)$/;
 
@@ -56,7 +56,6 @@
 
 export interface GrChangeListView {
   $: {
-    restAPI: RestApiService & Element;
     prevArrow: HTMLAnchorElement;
     nextArrow: HTMLAnchorElement;
   };
@@ -112,6 +111,8 @@
   @property({type: String})
   _repo: string | null = null;
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
   created() {
     super.created();
@@ -143,17 +144,9 @@
 
     // NOTE: This method may be called before attachment. Fire title-change
     // in an async so that attachment to the DOM can take place first.
-    this.async(() =>
-      this.dispatchEvent(
-        new CustomEvent('title-change', {
-          detail: {title: this._query},
-          composed: true,
-          bubbles: true,
-        })
-      )
-    );
+    this.async(() => fireTitleChange(this, this._query));
 
-    this.$.restAPI
+    this.restApiService
       .getPreferences()
       .then(prefs => {
         if (!prefs) {
@@ -165,12 +158,8 @@
       .then(changes => {
         changes = changes || [];
         if (this._query && changes.length === 1) {
-          let query: keyof typeof LookupQueryPatterns;
-          for (query in LookupQueryPatterns) {
-            if (
-              hasOwnProperty(LookupQueryPatterns, query) &&
-              this._query.match(LookupQueryPatterns[query])
-            ) {
+          for (const queryPattern of LOOKUP_QUERY_PATTERNS) {
+            if (this._query.match(queryPattern)) {
               // "Back"/"Forward" buttons work correctly only with
               // opt_redirect options
               GerritNav.navigateToChange(
@@ -190,9 +179,9 @@
   }
 
   _loadPreferences() {
-    return this.$.restAPI.getLoggedIn().then(loggedIn => {
+    return this.restApiService.getLoggedIn().then(loggedIn => {
       if (loggedIn) {
-        this.$.restAPI.getPreferences().then(preferences => {
+        this.restApiService.getPreferences().then(preferences => {
           this.preferences = preferences;
         });
       } else {
@@ -202,7 +191,7 @@
   }
 
   _getChanges() {
-    return this.$.restAPI.getChanges(
+    return this.restApiService.getChanges(
       this._changesPerPage,
       this._query,
       this._offset
@@ -289,11 +278,14 @@
   }
 
   _handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
-    this.$.restAPI.saveChangeStarred(e.detail.change._number, e.detail.starred);
+    this.restApiService.saveChangeStarred(
+      e.detail.change._number,
+      e.detail.starred
+    );
   }
 
   _handleToggleReviewed(e: CustomEvent<ChangeListToggleReviewedDetail>) {
-    this.$.restAPI.saveChangeReviewed(
+    this.restApiService.saveChangeReviewed(
       e.detail.change._number,
       e.detail.reviewed
     );
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.ts
index 0e8f843..9914e70 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.ts
@@ -98,5 +98,4 @@
       </a>
     </nav>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js
index af3acd8..445254a 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js
@@ -20,6 +20,7 @@
 import {page} from '../../../utils/page-wrapper-utils.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import 'lodash/lodash.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-change-list-view');
 
@@ -30,14 +31,10 @@
   let element;
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(false); },
-      getChanges(num, query) {
-        return Promise.resolve([]);
-      },
-      getAccountDetails() { return Promise.resolve({}); },
-      getAccountStatus() { return Promise.resolve({}); },
-    });
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    stubRestApi('getChanges').returns(Promise.resolve([]));
+    stubRestApi('getAccountDetails').returns(Promise.resolve({}));
+    stubRestApi('getAccountStatus').returns(Promise.resolve({}));
     element = basicFixture.instantiate();
   });
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index 3acdaf9..f26cd46 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -17,7 +17,6 @@
 
 import '../../../styles/gr-change-list-styles';
 import '../../shared/gr-cursor-manager/gr-cursor-manager';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-change-list-item/gr-change-list-item';
 import '../../../styles/shared-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
@@ -43,7 +42,6 @@
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {changeIsOpen, isOwner} from '../../../utils/change-util';
 import {customElement, property, observe} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
 import {
   AccountInfo,
@@ -56,6 +54,8 @@
   isAttentionSetEnabled,
 } from '../../../utils/attention-set-util';
 import {CustomKeyboardEvent} from '../../../types/events';
+import {fireEvent} from '../../../utils/event-util';
+import {windowLocationReload} from '../../../utils/dom-util';
 
 const NUMBER_FIXED_COLUMNS = 3;
 const CLOSED_STATUS = ['MERGED', 'ABANDONED'];
@@ -69,7 +69,6 @@
 }
 export interface GrChangeList {
   $: {
-    restAPI: RestApiService & Element;
     cursor: GrCursorManager;
   };
 }
@@ -147,6 +146,8 @@
 
   flagsService = appContext.flagsService;
 
+  private readonly restApiService = appContext.restApiService;
+
   keyboardShortcuts() {
     return {
       [Shortcut.CURSOR_NEXT_CHANGE]: '_nextChange',
@@ -169,7 +170,7 @@
   /** @override */
   ready() {
     super.ready();
-    this.$.restAPI.getConfig().then(config => {
+    this.restApiService.getConfig().then(config => {
       this._config = config;
     });
   }
@@ -227,7 +228,9 @@
         preferences && preferences.legacycid_in_change_table
       );
       if (preferences.change_table && preferences.change_table.length > 0) {
-        const prefColumns = this.getVisibleColumns(preferences.change_table);
+        const prefColumns = this.renameProjectToRepoColumn(
+          preferences.change_table
+        );
         this.visibleChangeTableColumns = this.getEnabledColumns(
           prefColumns,
           config,
@@ -393,6 +396,7 @@
 
     e.preventDefault();
     this.$.cursor.next();
+    this.selectedIndex = this.$.cursor.index;
   }
 
   _prevChange(e: CustomKeyboardEvent) {
@@ -402,6 +406,7 @@
 
     e.preventDefault();
     this.$.cursor.previous();
+    this.selectedIndex = this.$.cursor.index;
   }
 
   _openChange(e: CustomKeyboardEvent) {
@@ -424,12 +429,7 @@
     }
 
     e.preventDefault();
-    this.dispatchEvent(
-      new CustomEvent('next-page', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'next-page');
   }
 
   _prevPage(e: CustomKeyboardEvent) {
@@ -479,7 +479,7 @@
   }
 
   _reloadWindow() {
-    window.location.reload();
+    windowLocationReload();
   }
 
   _toggleChangeStar(e: CustomKeyboardEvent) {
@@ -521,6 +521,8 @@
     afterNextRender(this, () => {
       this.$.cursor.stops = this._getListItems();
       this.$.cursor.moveToStart();
+      if (this.selectedIndex)
+        this.$.cursor.setCursorAtIndex(this.selectedIndex);
     });
   }
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
index 06957d9..0313e0b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
@@ -82,7 +82,11 @@
         <template is="dom-if" if="[[_isEmpty(changeSection)]]">
           <tr class="noChanges">
             <td aria-hidden="true" class="leftPadding"></td>
-            <td aria-hidden="true" class="star" hidden></td>
+            <td
+              aria-hidden="[[!showStar]]"
+              class="star"
+              hidden$="[[!showStar]]"
+            ></td>
             <td
               class="cell"
               colspan$="[[_computeColspan(changeSection, visibleChangeTableColumns, labelNames)]]"
@@ -159,9 +163,7 @@
   </table>
   <gr-cursor-manager
     id="cursor"
-    index="{{selectedIndex}}"
     scroll-mode="keep-visible"
     focus-on-move=""
   ></gr-cursor-manager>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
index 35f3aeb..f320296 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
@@ -23,6 +23,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {customElement} from '@polymer/decorators';
 import {htmlTemplate} from './gr-create-change-help_html';
+import {fireEvent} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -43,8 +44,6 @@
    */
   _handleCreateTap(e: Event) {
     e.preventDefault();
-    this.dispatchEvent(
-      new CustomEvent('create-tap', {bubbles: true, composed: true})
-    );
+    fireEvent(this, 'create-tap');
   }
 }
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.ts b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.ts
index c2f97a6..add7ca5 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.ts
@@ -21,10 +21,10 @@
     :host {
       display: block;
     }
-    #graphic,
-    #help {
+    #graphic {
       display: inline-block;
       margin: var(--spacing-m);
+      margin-left: 0;
     }
     #graphic #circle {
       align-items: center;
@@ -36,7 +36,7 @@
       width: 10em;
     }
     #graphic iron-icon {
-      color: #9e9e9e;
+      color: var(--gray-foreground);
       height: 5em;
       width: 5em;
     }
@@ -45,6 +45,8 @@
       text-align: center;
     }
     #help {
+      display: inline-block;
+      margin: var(--spacing-m);
       padding-top: var(--spacing-xl);
       vertical-align: top;
     }
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
index 57d3dd9..a34bd63 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
@@ -19,7 +19,6 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-create-commands-dialog/gr-create-commands-dialog';
 import '../gr-create-change-help/gr-create-change-help';
 import '../gr-create-destination-dialog/gr-create-destination-dialog';
@@ -30,7 +29,6 @@
 import {htmlTemplate} from './gr-dashboard-view_html';
 import {
   GerritNav,
-  GerritView,
   UserDashboard,
   YOUR_TURN,
 } from '../../core/gr-navigation/gr-navigation';
@@ -47,7 +45,6 @@
   RepoName,
 } from '../../../types/common';
 import {AppElementDashboardParams, AppElementParams} from '../../gr-app-types';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {GrCreateCommandsDialog} from '../gr-create-commands-dialog/gr-create-commands-dialog';
 import {
@@ -58,12 +55,14 @@
 import {ChangeListToggleReviewedDetail} from '../gr-change-list-item/gr-change-list-item';
 import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
 import {DashboardViewState} from '../../../types/types';
+import {firePageError, fireTitleChange} from '../../../utils/event-util';
+import {GerritView} from '../../../services/router/router-model';
 
-const PROJECT_PLACEHOLDER_PATTERN = /\$\{project\}/g;
+const PROJECT_PLACEHOLDER_PATTERN = /\${project}/g;
+const RELOAD_DASHBOARD_INTERVAL_MS = 10 * 1000;
 
 export interface GrDashboardView {
   $: {
-    restAPI: RestApiService & Element;
     confirmDeleteDialog: GrDialog;
     commandsDialog: GrCreateCommandsDialog;
     destinationDialog: GrCreateDestinationDialog;
@@ -117,8 +116,15 @@
   @property({type: Boolean})
   _showNewUserHelp = false;
 
+  @property({type: Number})
+  _selectedChangeIndex?: number;
+
   private reporting = appContext.reportingService;
 
+  private readonly restApiService = appContext.restApiService;
+
+  private lastVisibleTimestampMs = 0;
+
   constructor() {
     super();
   }
@@ -129,14 +135,25 @@
     this._loadPreferences();
     this.addEventListener('reload', e => {
       e.stopPropagation();
-      this._reload();
+      this._reload(this.params);
+    });
+    document.addEventListener('visibilitychange', () => {
+      if (document.visibilityState === 'visible') {
+        if (
+          Date.now() - this.lastVisibleTimestampMs >
+          RELOAD_DASHBOARD_INTERVAL_MS
+        )
+          this._reload(this.params);
+      } else {
+        this.lastVisibleTimestampMs = Date.now();
+      }
     });
   }
 
   _loadPreferences() {
-    return this.$.restAPI.getLoggedIn().then(loggedIn => {
+    return this.restApiService.getLoggedIn().then(loggedIn => {
       if (loggedIn) {
-        this.$.restAPI.getPreferences().then(preferences => {
+        this.restApiService.getPreferences().then(preferences => {
           this.preferences = preferences;
         });
       } else {
@@ -150,15 +167,9 @@
     dashboard: DashboardId
   ): Promise<UserDashboard | undefined> {
     const errFn = (response?: Response | null) => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(response);
     };
-    return this.$.restAPI
+    return this.restApiService
       .getDashboard(project, dashboard, errFn)
       .then(response => {
         if (!response) {
@@ -191,12 +202,21 @@
     return params.view === GerritView.DASHBOARD;
   }
 
+  @observe('_selectedChangeIndex')
+  _selectedChangeIndexChanged(selectedChangeIndex: number) {
+    if (!this.params || !this._isViewActive(this.params)) return;
+    if (!this.viewState) throw new Error('view state undefined');
+    if (!this.params.user) throw new Error('user for dashboard is undefined');
+    this.viewState[this.params.user] = selectedChangeIndex;
+  }
+
   @observe('params.*')
   _paramsChanged(
     paramsChangeRecord: ElementPropertyDeepChange<GrDashboardView, 'params'>
   ) {
     const params = paramsChangeRecord.base;
-
+    if (params && this._isViewActive(params) && params.user && this.viewState)
+      this._selectedChangeIndex = this.viewState[params.user] || 0;
     return this._reload(params);
   }
 
@@ -211,7 +231,7 @@
     const {project, dashboard, title, user, sections} = params;
     const dashboardPromise: Promise<UserDashboard | undefined> = project
       ? this._getProjectDashboard(project, dashboard)
-      : this.$.restAPI
+      : this.restApiService
           .getConfig()
           .then(config =>
             Promise.resolve(
@@ -224,17 +244,13 @@
             )
           );
 
-    const checkForNewUser = !project && user === 'self';
+    // Checking `this.account` to make sure that the user is logged in.
+    // Otherwise sending a query for 'owner:self' will result in an error.
+    const checkForNewUser = !project && !!this.account && user === 'self';
     return dashboardPromise
       .then(res => {
         if (res && res.title) {
-          this.dispatchEvent(
-            new CustomEvent('title-change', {
-              detail: {title: res.title},
-              composed: true,
-              bubbles: true,
-            })
-          );
+          fireTitleChange(this, res.title);
         }
         return this._fetchDashboardChanges(res, checkForNewUser);
       })
@@ -243,15 +259,7 @@
         this.reporting.dashboardDisplayed();
       })
       .catch(err => {
-        this.dispatchEvent(
-          new CustomEvent('title-change', {
-            detail: {
-              title: title || this._computeTitle(user),
-            },
-            composed: true,
-            bubbles: true,
-          })
-        );
+        fireTitleChange(this, title || this._computeTitle(user));
         console.warn(err);
       })
       .then(() => {
@@ -289,7 +297,7 @@
       }
     }
 
-    return this.$.restAPI.getChanges(undefined, queries).then(changes => {
+    return this.restApiService.getChanges(undefined, queries).then(changes => {
       if (!changes) {
         throw new Error('getChanges returns undefined');
       }
@@ -368,14 +376,43 @@
   }
 
   _handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
-    this.$.restAPI.saveChangeStarred(e.detail.change._number, e.detail.starred);
+    this.restApiService.saveChangeStarred(
+      e.detail.change._number,
+      e.detail.starred
+    );
+    // When a change is updated the same change may appear elsewhere in the
+    // dashboard (but is not the same object), so we must update other
+    // occurrences of the same change.
+    this._results?.forEach((dashboardChange, dashboardIndex) =>
+      dashboardChange.results.forEach((change, changeIndex) => {
+        if (change.id === e.detail.change.id) {
+          this.set(
+            `_results.${dashboardIndex}.results.${changeIndex}.starred`,
+            e.detail.starred
+          );
+        }
+      })
+    );
   }
 
   _handleToggleReviewed(e: CustomEvent<ChangeListToggleReviewedDetail>) {
-    this.$.restAPI.saveChangeReviewed(
+    this.restApiService.saveChangeReviewed(
       e.detail.change._number,
       e.detail.reviewed
     );
+    // When a change is updated the same change may appear elsewhere in the
+    // dashboard (but is not the same object), so we must update other
+    // occurrences of the same change.
+    this._results?.forEach((dashboardChange, dashboardIndex) =>
+      dashboardChange.results.forEach((change, changeIndex) => {
+        if (change.id === e.detail.change.id) {
+          this.set(
+            `_results.${dashboardIndex}.results.${changeIndex}.reviewed`,
+            e.detail.reviewed
+          );
+        }
+      })
+    );
   }
 
   /**
@@ -419,7 +456,7 @@
 
   _handleConfirmDelete() {
     this.$.confirmDeleteDialog.disabled = true;
-    return this.$.restAPI.deleteDraftComments('-is:open').then(() => {
+    return this.restApiService.deleteDraftComments('-is:open').then(() => {
       this._closeConfirmDeleteOverlay();
       this._reload(this.params);
     });
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
index 6dae176..730797c 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
@@ -50,9 +50,6 @@
     #emptyOutgoing {
       display: block;
     }
-    #emptyYourTurn {
-      text-align: center;
-    }
     @media only screen and (max-width: 50em) {
       .loading {
         padding: 0 var(--spacing-l);
@@ -84,7 +81,7 @@
       show-reviewed-state=""
       account="[[account]]"
       preferences="[[preferences]]"
-      selected-index="{{viewState.selectedChangeIndex}}"
+      selected-index="{{_selectedChangeIndex}}"
       sections="[[_results]]"
       on-toggle-star="_handleToggleStar"
       on-toggle-reviewed="_handleToggleReviewed"
@@ -100,7 +97,7 @@
         </template>
       </div>
       <div id="emptyYourTurn" slot="empty-your-turn">
-        <span>&#x1f389; No changes need your attention &#x1f389;</span>
+        <span>No changes need your attention &nbsp;&#x1f389;</span>
       </div>
     </gr-change-list>
   </div>
@@ -125,5 +122,4 @@
     on-confirm="_handleDestinationConfirm"
   ></gr-create-destination-dialog>
   <gr-create-commands-dialog id="commandsDialog"></gr-create-commands-dialog>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
index 44f203d..a5de72b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
@@ -18,9 +18,12 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-dashboard-view.js';
 import {isHidden} from '../../../test/test-utils.js';
-import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {GerritView} from '../../../services/router/router-model.js';
 import {changeIsOpen} from '../../../utils/change-util.js';
 import {ChangeStatus} from '../../../constants/constants.js';
+import {createAccountWithId} from '../../../test/test-data-generators.js';
+import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-dashboard-view');
 
@@ -31,16 +34,14 @@
   let getChangesStub;
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(false); },
-      getAccountDetails() { return Promise.resolve({}); },
-      getAccountStatus() { return Promise.resolve(false); },
-    });
-    element = basicFixture.instantiate();
-
-    getChangesStub = sinon.stub(element.$.restAPI, 'getChanges').callsFake(
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    stubRestApi('getAccountDetails').returns(Promise.resolve({}));
+    stubRestApi('getAccountStatus').returns(Promise.resolve(false));
+    getChangesStub= stubRestApi('getChanges').callsFake(
         (_, qs) => Promise.resolve(qs.map(() => [])));
 
+    element = basicFixture.instantiate();
+
     let resolver;
     paramsChangedPromise = new Promise(resolve => {
       resolver = resolve;
@@ -123,15 +124,14 @@
       const deleteDraftCommentsPromise = new Promise(resolve => {
         deleteDraftCommentsPromiseResolver = resolve;
       });
-      sinon.stub(element.$.restAPI, 'deleteDraftComments')
+      const deleteStub = stubRestApi('deleteDraftComments')
           .returns(deleteDraftCommentsPromise);
 
       // Open confirmation dialog and tap confirm button.
       await element.$.confirmDeleteOverlay.open();
       MockInteractions.tap(element.$.confirmDeleteDialog.$.confirm);
       flush();
-      assert.isTrue(element.$.restAPI.deleteDraftComments
-          .calledWithExactly('-is:open'));
+      assert.isTrue(deleteStub.calledWithExactly('-is:open'));
       assert.isTrue(element.$.confirmDeleteDialog.disabled);
       assert.equal(element._reload.callCount, 0);
 
@@ -201,9 +201,23 @@
         user: 'self',
       };
       return paramsChangedPromise.then(() => {
-        assert.isTrue(
-            getChangesStub.calledWith(undefined,
-                ['1', '2', 'owner:self limit:1']));
+        assert.isTrue(getChangesStub.calledWith(undefined, ['1', '2']));
+      });
+    });
+
+    test('viewing dashboard when logged in includes owner:self query', () => {
+      element.account = createAccountWithId(1);
+      element.params = {
+        view: GerritNav.View.DASHBOARD,
+        sections: [
+          {query: '1'},
+          {query: '2', selfOnly: true},
+        ],
+        user: 'self',
+      };
+      return paramsChangedPromise.then(() => {
+        assert.isTrue(getChangesStub.calledWith(undefined,
+            ['1', '2', 'owner:self limit:1']));
       });
     });
 
@@ -239,7 +253,7 @@
 
   suite('_getProjectDashboard', () => {
     test('dashboard with foreach', () => {
-      sinon.stub(element.$.restAPI, 'getDashboard')
+      stubRestApi('getDashboard')
           .callsFake( () => Promise.resolve({
             title: 'title',
             foreach: 'foreach for ${project}',
@@ -265,7 +279,7 @@
     });
 
     test('dashboard without foreach', () => {
-      sinon.stub(element.$.restAPI, 'getDashboard').callsFake(
+      stubRestApi('getDashboard').callsFake(
           () => Promise.resolve({
             title: 'title',
             sections: [
@@ -293,7 +307,7 @@
       {name: 'test2', query: 'test2', hideIfEmpty: true},
     ];
     getChangesStub.restore();
-    sinon.stub(element.$.restAPI, 'getChanges')
+    stubRestApi('getChanges')
         .returns(Promise.resolve([[], ['nonempty']]));
 
     return element._fetchDashboardChanges({sections}, false).then(() => {
@@ -308,7 +322,7 @@
       {name: 'test2', query: 'test2'},
     ];
     getChangesStub.restore();
-    sinon.stub(element.$.restAPI, 'getChanges')
+    stubRestApi('getChanges')
         .returns(Promise.resolve([[], []]));
 
     return element._fetchDashboardChanges({sections}, false).then(() => {
@@ -318,6 +332,56 @@
     });
   });
 
+  test('toggling star will update change everywhere', () => {
+    // It is important that the same change is represented by multiple objects
+    // and all are updated.
+    const change = {id: '5', starred: false};
+    const sameChange = {id: '5', starred: false};
+    const differentChange = {id: '4', starred: false};
+    element._results = [
+      {query: 'has:draft', results: [change]},
+      {query: 'is:open', results: [sameChange, differentChange]},
+    ];
+
+    element._handleToggleStar(
+        new CustomEvent('toggle-star', {
+          detail: {
+            change,
+            starred: true,
+          },
+        })
+    );
+
+    assert.isTrue(change.starred);
+    assert.isTrue(sameChange.starred);
+    assert.isFalse(differentChange.starred);
+  });
+
+  test('toggling reviewed will update change everywhere', () => {
+    // It is important that the same change is represented by multiple objects
+    // and all are updated.
+    const change = {id: '5', reviewed: false};
+    const sameChange = {id: '5', reviewed: false};
+    const differentChange = {id: '4', reviewed: false};
+    element._results = [
+      {query: 'has:draft', results: [change]},
+      {query: 'is:open', results: [sameChange, differentChange]},
+    ];
+
+    element._handleToggleReviewed(
+        new CustomEvent('toggle-reviewed', {
+          detail: {
+            change,
+            reviewed: true,
+          },
+        })
+    );
+
+    assert.isTrue(change.reviewed);
+    assert.isTrue(sameChange.reviewed);
+    assert.isFalse(differentChange.reviewed);
+  });
+
   test('_showNewUserHelp', () => {
     element._loading = false;
     element._showNewUserHelp = false;
@@ -359,11 +423,11 @@
 
   test('404 page', done => {
     const response = {status: 404};
-    sinon.stub(element.$.restAPI, 'getDashboard').callsFake(
+    stubRestApi('getDashboard').callsFake(
         async (project, dashboard, errFn) => {
           errFn(response);
         });
-    element.addEventListener('page-error', e => {
+    addListenerForTest(document, 'page-error', e => {
       assert.strictEqual(e.detail.response, response);
       paramsChangedPromise.then(done);
     });
@@ -374,15 +438,39 @@
     };
   });
 
-  test('params change triggers dashboardDisplayed()', () => {
+  test('params change triggers dashboardDisplayed()', async () => {
+    stubRestApi('getDashboard').returns(Promise.resolve({
+      title: 'title',
+      sections: [],
+    }));
     sinon.stub(element.reporting, 'dashboardDisplayed');
     element.params = {
       view: GerritNav.View.DASHBOARD,
       project: 'project',
       dashboard: 'dashboard',
     };
-    return paramsChangedPromise.then(() => {
-      assert.isTrue(element.reporting.dashboardDisplayed.calledOnce);
+    await paramsChangedPromise;
+    assert.isTrue(element.reporting.dashboardDisplayed.calledOnce);
+  });
+
+  test('selectedChangeIndex is derived from the params', () => {
+    stubRestApi('getDashboard').returns(Promise.resolve({
+      title: 'title',
+      sections: [],
+    }));
+    element.viewState = {
+      101001: 23,
+    };
+    element.params = {
+      view: GerritNav.View.DASHBOARD,
+      project: 'project',
+      dashboard: 'dashboard',
+      user: '101001',
+    };
+    flush();
+    sinon.stub(element.reporting, 'dashboardDisplayed');
+    paramsChangedPromise.then(() => {
+      assert.equal(element._selectedChangeIndex, 23);
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
index e501cfd..35a2a7f 100644
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
@@ -18,7 +18,6 @@
 import '../../../styles/dashboard-header-styles';
 import '../../../styles/shared-styles';
 import '../../shared/gr-date-formatter/gr-date-formatter';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.ts b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.ts
index d1221d15..2dd2913 100644
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.ts
@@ -30,5 +30,4 @@
     <hr />
     <div><span>Detail:</span> <a href$="[[_repoUrl]]">Repo settings</a></div>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
index 055c82c..cfee0cd 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
@@ -20,7 +20,6 @@
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../shared/gr-avatar/gr-avatar';
 import '../../shared/gr-date-formatter/gr-date-formatter';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/dashboard-header-styles';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
@@ -28,15 +27,9 @@
 import {htmlTemplate} from './gr-user-header_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 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 {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
+import {appContext} from '../../../services/app-context';
 
 @customElement('gr-user-header')
 export class GrUserHeader extends GestureEventListeners(
@@ -61,6 +54,8 @@
   @property({type: String})
   _status = '';
 
+  private readonly restApiService = appContext.restApiService;
+
   _accountChanged(userId?: AccountId) {
     if (!userId) {
       this._accountDetails = null;
@@ -68,7 +63,7 @@
       return;
     }
 
-    this.$.restAPI.getAccountDetails(userId).then(details => {
+    this.restApiService.getAccountDetails(userId).then(details => {
       this._accountDetails = details ?? null;
       this._status = details?.status ?? '';
     });
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 002a4ba..a8a6a56 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
@@ -66,5 +66,4 @@
       <a href$="[[_computeDashboardUrl(_accountDetails)]]">View dashboard</a>
     </div>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.js b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.js
index a808f3c..4ec799f 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-user-header.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-user-header');
 
@@ -28,7 +29,7 @@
   });
 
   test('loads and clears account info', done => {
-    sinon.stub(element.$.restAPI, 'getAccountDetails')
+    stubRestApi('getAccountDetails')
         .returns(Promise.resolve({
           name: 'foo',
           email: 'bar',
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 3f6dd23..87b09c7 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
@@ -19,9 +19,7 @@
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-dropdown/gr-dropdown';
 import '../../shared/gr-icons/gr-icons';
-import '../../shared/gr-js-api-interface/gr-js-api-interface';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
 import '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog';
 import '../gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog';
@@ -39,12 +37,10 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {appContext} from '../../../services/app-context';
-import {
-  fetchChangeUpdates,
-  patchNumEquals,
-} from '../../../utils/patch-set-util';
+import {fetchChangeUpdates, CURRENT} from '../../../utils/patch-set-util';
 import {
   changeIsOpen,
+  isOwner,
   ListChangesOption,
   listChangesOptionsToHex,
 } from '../../../utils/change-util';
@@ -54,16 +50,10 @@
   HttpMethod,
   NotifyType,
 } from '../../../constants/constants';
-import {EventType, TargetElement} from '../../plugins/gr-plugin-types';
+import {EventType as PluginEventType, TargetElement} from '../../../api/plugin';
 import {customElement, observe, property} from '@polymer/decorators';
-import {GrJsApiInterface} from '../../shared/gr-js-api-interface/gr-js-api-interface-element';
 import {
-  ActionPriority,
-  ActionType,
-  ErrorCallback,
-  RestApiService,
-} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {
+  AccountInfo,
   ActionInfo,
   ActionNameToActionInfoMap,
   BranchName,
@@ -104,12 +94,25 @@
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {
-  ChangeActions,
   GrChangeActionsElement,
-  PrimaryActionKey,
-  RevisionActions,
   UIActionInfo,
 } from '../../shared/gr-js-api-interface/gr-change-actions-js-api';
+import {fireAlert} from '../../../utils/event-util';
+import {
+  CODE_REVIEW,
+  getApprovalInfo,
+  getVotingRange,
+} from '../../../utils/label-util';
+import {CommentThread} from '../../../utils/comment-util';
+import {ShowAlertEventDetail} from '../../../types/events';
+import {
+  ActionPriority,
+  ActionType,
+  ChangeActions,
+  PrimaryActionKey,
+  RevisionActions,
+} from '../../../api/change-actions';
+import {ErrorCallback} from '../../../api/rest';
 
 const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
 const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
@@ -312,12 +315,11 @@
 
 interface ChangeActionDialog extends HTMLElement {
   resetFocus?(): void;
+  init?(): void;
 }
 
 export interface GrChangeActions {
   $: {
-    jsAPI: GrJsApiInterface;
-    restAPI: RestApiService & Element;
     mainContent: Element;
     overlay: GrOverlay;
     confirmRebase: GrConfirmRebaseDialog;
@@ -378,6 +380,8 @@
 
   reporting = appContext.reportingService;
 
+  private readonly jsAPI = appContext.jsApiService;
+
   @property({type: Object})
   change?: ChangeViewChangeInfo;
 
@@ -399,6 +403,9 @@
   @property({type: Boolean})
   _hideQuickApproveAction = false;
 
+  @property({type: Object})
+  account?: AccountInfo;
+
   @property({type: String})
   changeNum?: NumericChangeId;
 
@@ -435,6 +442,9 @@
   @property({type: String})
   _actionLoadingMessage = '';
 
+  @property({type: Array})
+  commentThreads: CommentThread[] = [];
+
   @property({
     type: Array,
     computed:
@@ -543,6 +553,8 @@
   @property({type: Object})
   _config?: ServerInfo;
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
   created() {
     super.created();
@@ -557,8 +569,8 @@
   /** @override */
   ready() {
     super.ready();
-    this.$.jsAPI.addElement(TargetElement.CHANGE_ACTIONS, this);
-    this.$.restAPI.getConfig().then(config => {
+    this.jsAPI.addElement(TargetElement.CHANGE_ACTIONS, this);
+    this.restApiService.getConfig().then(config => {
       this._config = config;
     });
     this._handleLoadingComplete();
@@ -594,7 +606,7 @@
     const change = this.change;
 
     this._loading = true;
-    return this.$.restAPI
+    return this.restApiService
       .getChangeRevisionActions(this.changeNum, this.latestPatchNum)
       .then(revisionActions => {
         if (!revisionActions) {
@@ -609,13 +621,7 @@
         this._handleLoadingComplete();
       })
       .catch(err => {
-        this.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message: ERR_REVISION_ACTIONS},
-            composed: true,
-            bubbles: true,
-          })
-        );
+        fireAlert(this, ERR_REVISION_ACTIONS);
         this._loading = false;
         throw err;
       });
@@ -631,7 +637,7 @@
     change: ChangeInfo;
     revisionActions: ActionNameToActionInfoMap;
   }) {
-    this.$.jsAPI.handleEvent(EventType.SHOW_REVISION_ACTIONS, detail);
+    this.jsAPI.handleEvent(PluginEventType.SHOW_REVISION_ACTIONS, detail);
   }
 
   @observe('change')
@@ -889,6 +895,7 @@
       if (editMode && !editPatchsetLoaded) {
         if (!actions.stopEdit) {
           this.set('actions.stopEdit', STOP_EDIT);
+          fireAlert(this, 'Change is in edit mode');
         }
       } else {
         this._deleteAndNotify('stopEdit');
@@ -926,15 +933,18 @@
     if (!this.change || !this.change.labels || !this.change.permitted_labels) {
       return null;
     }
+    if (this.change && this.change.status === ChangeStatus.MERGED) {
+      return null;
+    }
     let result;
-    for (const label in this.change.labels) {
+    for (const [label, labelInfo] of Object.entries(this.change.labels)) {
       if (!(label in this.change.permitted_labels)) {
         continue;
       }
       if (this.change.permitted_labels[label].length === 0) {
         continue;
       }
-      const status = this._getLabelStatus(this.change.labels[label]);
+      const status = this._getLabelStatus(labelInfo);
       if (status === LabelStatus.NEED) {
         if (result) {
           // More than one label is missing, so it's unclear which to quick
@@ -949,18 +959,39 @@
         return null;
       }
     }
+    // Allow the user to use quick approve to vote the max score on code review
+    // even if it is already granted by someone else. Does not apply if the
+    // user owns the change or has already granted the max score themselves.
+    const codeReviewLabel = this.change.labels[CODE_REVIEW];
+    const codeReviewPermittedValues = this.change.permitted_labels[CODE_REVIEW];
+    if (
+      !result &&
+      codeReviewLabel &&
+      codeReviewPermittedValues &&
+      this.account?._account_id &&
+      isDetailedLabelInfo(codeReviewLabel) &&
+      this._getLabelStatus(codeReviewLabel) === LabelStatus.OK &&
+      !isOwner(this.change, this.account) &&
+      getApprovalInfo(codeReviewLabel, this.account)?.value !==
+        getVotingRange(codeReviewLabel)?.max
+    ) {
+      result = CODE_REVIEW;
+    }
+
     if (result) {
-      const score = this.change.permitted_labels[result].slice(-1)[0];
       const labelInfo = this.change.labels[result];
       if (!isDetailedLabelInfo(labelInfo)) {
         return null;
       }
-      const maxScore = Object.keys(labelInfo.values).slice(-1)[0];
-      if (score === maxScore) {
+      const permittedValues = this.change.permitted_labels[result];
+      const usersMaxPermittedScore =
+        permittedValues[permittedValues.length - 1];
+      const maxScoreForLabel = getVotingRange(labelInfo)?.max;
+      if (Number(usersMaxPermittedScore) === maxScoreForLabel) {
         // Allow quick approve only for maximal score.
         return {
           label: result,
-          score,
+          score: usersMaxPermittedScore,
         };
       }
     }
@@ -1074,7 +1105,7 @@
     if (!this.changeNum) {
       return;
     }
-    this.$.restAPI
+    this.restApiService
       .getChangeActionURL(this.changeNum, patchNum, '/' + action.__key)
       .then(url => (action.__url = url));
   }
@@ -1113,7 +1144,7 @@
     if (!this.change) {
       return false;
     }
-    return this.$.jsAPI.canSubmitChange(
+    return this.jsAPI.canSubmitChange(
       this.change,
       this._getRevision(this.change, this.latestPatchNum)
     );
@@ -1121,7 +1152,7 @@
 
   _getRevision(change: ChangeViewChangeInfo, patchNum?: PatchSetNum) {
     for (const rev of Object.values(change.revisions)) {
-      if (patchNumEquals(rev._number, patchNum)) {
+      if (rev._number === patchNum) {
         return rev;
       }
     }
@@ -1136,9 +1167,9 @@
     /* A chromium plugin expects that the modifyRevertMsg hook will only
     be called after the revert button is pressed, hence we populate the
     revert dialog after revert button is pressed. */
-    this.$.restAPI.getChanges(0, query).then(changes => {
+    this.restApiService.getChanges(0, query).then(changes => {
       if (!changes) {
-        console.error('changes is undefined');
+        this.reporting.error(new Error('changes is undefined'));
         return;
       }
       this.$.confirmRevertDialog.populate(change, this.commitMessage, changes);
@@ -1150,9 +1181,9 @@
     const change = this.change;
     if (!change) return;
     const query = `submissionid:${change.submission_id}`;
-    this.$.restAPI.getChanges(0, query).then(changes => {
+    this.restApiService.getChanges(0, query).then(changes => {
       if (!changes) {
-        console.error('changes is undefined');
+        this.reporting.error(new Error('changes is undefined'));
         return;
       }
       this.$.confirmRevertSubmissionDialog._populateRevertSubmissionMessage(
@@ -1370,23 +1401,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,
-        })
-      );
+      fireAlert(this, ERR_BRANCH_EMPTY);
       return;
     }
     if (!el.message) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: ERR_COMMIT_EMPTY},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, ERR_COMMIT_EMPTY);
       return;
     }
     this.$.overlay.close();
@@ -1407,13 +1426,7 @@
   _handleMoveConfirm() {
     const el = this.$.confirmMove;
     if (!el.branch) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: ERR_BRANCH_EMPTY},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, ERR_BRANCH_EMPTY);
       return;
     }
     this.$.overlay.close();
@@ -1448,7 +1461,7 @@
         );
         break;
       default:
-        console.error('invalid revert type');
+        this.reporting.error(new Error('invalid revert type'));
     }
   }
 
@@ -1584,7 +1597,7 @@
 
   _showActionDialog(dialog: ChangeActionDialog) {
     this._hideAllDialogs();
-
+    if (dialog.init) dialog.init();
     dialog.hidden = false;
     this.$.overlay.open().then(() => {
       if (dialog.resetFocus) {
@@ -1595,24 +1608,24 @@
 
   // TODO(rmistry): Redo this after
   // https://bugs.chromium.org/p/gerrit/issues/detail?id=4671 is resolved.
-  _setLabelValuesOnRevert(newChangeId: NumericChangeId) {
-    const labels = this.$.jsAPI.getLabelValuesPostRevert(this.change);
-    if (!labels) {
+  _setReviewOnRevert(newChangeId: NumericChangeId) {
+    const review = this.jsAPI.getReviewPostRevert(this.change);
+    if (!review) {
       return Promise.resolve(undefined);
     }
-    return this.$.restAPI.saveChangeReview(newChangeId, 'current', {labels});
+    return this.restApiService.saveChangeReview(newChangeId, CURRENT, review);
   }
 
   _handleResponse(action: UIActionInfo, response?: Response) {
     if (!response) {
       return;
     }
-    return this.$.restAPI.getResponseObject(response).then(obj => {
+    return this.restApiService.getResponseObject(response).then(obj => {
       switch (action.__key) {
         case ChangeActions.REVERT: {
           const revertChangeInfo: ChangeInfo = (obj as unknown) as ChangeInfo;
           this._waitForChangeReachable(revertChangeInfo._number)
-            .then(() => this._setLabelValuesOnRevert(revertChangeInfo._number))
+            .then(() => this._setReviewOnRevert(revertChangeInfo._number))
             .then(() => {
               GerritNav.navigateToChange(revertChangeInfo);
             });
@@ -1735,10 +1748,10 @@
         new Error('Properties change and changeNum must be set.')
       );
     }
-    return fetchChangeUpdates(change, this.$.restAPI).then(result => {
+    return fetchChangeUpdates(change, this.restApiService).then(result => {
       if (!result.isLatest) {
         this.dispatchEvent(
-          new CustomEvent('show-alert', {
+          new CustomEvent<ShowAlertEventDetail>('show-alert', {
             detail: {
               message:
                 'Cannot set label: a newer patch has been ' +
@@ -1766,7 +1779,7 @@
         return Promise.resolve(undefined);
       }
       const patchNum = revisionAction ? this.latestPatchNum : undefined;
-      return this.$.restAPI
+      return this.restApiService
         .executeChangeAction(
           changeNum,
           method,
@@ -1796,14 +1809,16 @@
       ListChangesOption.MESSAGES,
       ListChangesOption.ALL_REVISIONS
     );
-    this.$.restAPI.getChanges(0, query, undefined, options).then(changes => {
-      if (!changes) {
-        console.error('getChanges returns undefined');
-        return;
-      }
-      this.$.confirmCherrypick.updateChanges(changes);
-      this._showActionDialog(this.$.confirmCherrypick);
-    });
+    this.restApiService
+      .getChanges(0, query, undefined, options)
+      .then(changes => {
+        if (!changes) {
+          this.reporting.error(new Error('getChanges returns undefined'));
+          return;
+        }
+        this.$.confirmCherrypick.updateChanges(changes);
+        this._showActionDialog(this.$.confirmCherrypick);
+      });
   }
 
   _handleMoveTap() {
@@ -2053,12 +2068,12 @@
    *
    */
   _waitForChangeReachable(changeNum: NumericChangeId) {
-    let attempsRemaining = AWAIT_CHANGE_ATTEMPTS;
+    let attemptsRemaining = AWAIT_CHANGE_ATTEMPTS;
     return new Promise(resolve => {
       const check = () => {
-        attempsRemaining--;
+        attemptsRemaining--;
         // Pass a no-op error handler to avoid the "not found" error toast.
-        this.$.restAPI
+        this.restApiService
           .getChange(changeNum, () => {})
           .then(response => {
             // If the response is 404, the response will be undefined.
@@ -2067,7 +2082,7 @@
               return;
             }
 
-            if (attempsRemaining) {
+            if (attemptsRemaining) {
               this.async(check, AWAIT_CHANGE_TIMEOUT_MS);
             } else {
               resolve(false);
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
index 4e315af..71500ac 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
@@ -216,6 +216,7 @@
       action="[[_revisionSubmitAction]]"
       on-cancel="_handleConfirmDialogCancel"
       on-confirm="_handleSubmitConfirm"
+      comment-threads="[[commentThreads]]"
       hidden=""
     ></gr-confirm-submit-dialog>
     <gr-dialog
@@ -269,6 +270,4 @@
       </div>
     </gr-dialog>
   </gr-overlay>
-  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
index ae49a57..fbb70b7 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
@@ -21,10 +21,14 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
 import {
+  createAccountWithId,
+  createApproval,
   createChange,
   createChangeMessages,
   createRevisions,
 } from '../../../test/test-data-generators.js';
+import {ChangeStatus} from '../../../constants/constants.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-change-actions');
 
@@ -38,56 +42,50 @@
 
   suite('basic tests', () => {
     setup(() => {
-      stub('gr-rest-api-interface', {
-        getChangeRevisionActions() {
+      stubRestApi('getChangeRevisionActions').returns(Promise.resolve({
+        cherrypick: {
+          method: 'POST',
+          label: 'Cherry Pick',
+          title: 'Cherry pick change to a different branch',
+          enabled: true,
+        },
+        rebase: {
+          method: 'POST',
+          label: 'Rebase',
+          title: 'Rebase onto tip of branch or parent change',
+          enabled: true,
+        },
+        submit: {
+          method: 'POST',
+          label: 'Submit',
+          title: 'Submit patch set 2 into master',
+          enabled: true,
+        },
+        revert_submission: {
+          method: 'POST',
+          label: 'Revert submission',
+          title: 'Revert this submission',
+          enabled: true,
+        },
+      }));
+      stubRestApi('send').callsFake((method, url, payload) => {
+        if (method !== 'POST') {
+          return Promise.reject(new Error('bad method'));
+        }
+        if (url === '/changes/test~42/revisions/2/submit') {
           return Promise.resolve({
-            cherrypick: {
-              method: 'POST',
-              label: 'Cherry Pick',
-              title: 'Cherry pick change to a different branch',
-              enabled: true,
-            },
-            rebase: {
-              method: 'POST',
-              label: 'Rebase',
-              title: 'Rebase onto tip of branch or parent change',
-              enabled: true,
-            },
-            submit: {
-              method: 'POST',
-              label: 'Submit',
-              title: 'Submit patch set 2 into master',
-              enabled: true,
-            },
-            revert_submission: {
-              method: 'POST',
-              label: 'Revert submission',
-              title: 'Revert this submission',
-              enabled: true,
-            },
+            ok: true,
+            text() { return Promise.resolve(')]}\'\n{}'); },
           });
-        },
-        send(method, url, payload) {
-          if (method !== 'POST') {
-            return Promise.reject(new Error('bad method'));
-          }
-
-          if (url === '/changes/test~42/revisions/2/submit') {
-            return Promise.resolve({
-              ok: true,
-              text() { return Promise.resolve(')]}\'\n{}'); },
-            });
-          } else if (url === '/changes/test~42/revisions/2/rebase') {
-            return Promise.resolve({
-              ok: true,
-              text() { return Promise.resolve(')]}\'\n{}'); },
-            });
-          }
-
-          return Promise.reject(new Error('bad url'));
-        },
-        getProjectConfig() { return Promise.resolve({}); },
+        } else if (url === '/changes/test~42/revisions/2/rebase') {
+          return Promise.resolve({
+            ok: true,
+            text() { return Promise.resolve(')]}\'\n{}'); },
+          });
+        }
+        return Promise.reject(new Error('bad url'));
       });
+      stubRestApi('getProjectConfig').returns(Promise.resolve({}));
 
       sinon.stub(getPluginLoader(), 'awaitPluginsLoaded')
           .returns(Promise.resolve());
@@ -104,10 +102,10 @@
           enabled: true,
         },
       };
-      sinon.stub(element.$.confirmCherrypick.$.restAPI,
-          'getRepoBranches').returns(Promise.resolve([]));
-      sinon.stub(element.$.confirmMove.$.restAPI,
-          'getRepoBranches').returns(Promise.resolve([]));
+      element.account = {
+        _account_id: 123,
+      };
+      stubRestApi('getRepoBranches').returns(Promise.resolve([]));
 
       return element.reload();
     });
@@ -143,14 +141,14 @@
     });
 
     test('plugin revision actions', done => {
-      sinon.stub(element.$.restAPI, 'getChangeActionURL').returns(
+      const stub = stubRestApi('getChangeActionURL').returns(
           Promise.resolve('the-url'));
       element.revisionActions = {
         'plugin~action': {},
       };
       assert.isOk(element.revisionActions['plugin~action']);
       flush(() => {
-        assert.isTrue(element.$.restAPI.getChangeActionURL.calledWith(
+        assert.isTrue(stub.calledWith(
             element.changeNum, element.latestPatchNum, '/plugin~action'));
         assert.equal(element.revisionActions['plugin~action'].__url, 'the-url');
         done();
@@ -158,14 +156,14 @@
     });
 
     test('plugin change actions', async () => {
-      sinon.stub(element.$.restAPI, 'getChangeActionURL').returns(
+      const stub = stubRestApi('getChangeActionURL').returns(
           Promise.resolve('the-url'));
       element.actions = {
         'plugin~action': {},
       };
       assert.isOk(element.actions['plugin~action']);
       await flush();
-      assert.isTrue(element.$.restAPI.getChangeActionURL.calledWith(
+      assert.isTrue(stub.calledWith(
           element.changeNum, undefined, '/plugin~action'));
       assert.equal(element.actions['plugin~action'].__url, 'the-url');
     });
@@ -253,7 +251,7 @@
           rev2: revObj,
         },
       };
-      assert.deepEqual(element._getRevision(change, '2'), revObj);
+      assert.deepEqual(element._getRevision(change, 2), revObj);
     });
 
     test('_actionComparator sort order', () => {
@@ -273,7 +271,7 @@
 
     test('submit change', () => {
       const showSpy = sinon.spy(element, '_showActionDialog');
-      sinon.stub(element.$.restAPI, 'getFromProjectLookup')
+      stubRestApi('getFromProjectLookup')
           .returns(Promise.resolve('test'));
       sinon.stub(element.$.overlay, 'open').returns(Promise.resolve());
       element.change = {
@@ -295,7 +293,7 @@
 
     test('submit change, tap on icon', done => {
       sinon.stub(element.$.confirmSubmitDialog, 'resetFocus').callsFake( done);
-      sinon.stub(element.$.restAPI, 'getFromProjectLookup')
+      stubRestApi('getFromProjectLookup')
           .returns(Promise.resolve('test'));
       sinon.stub(element.$.overlay, 'open').returns(Promise.resolve());
       element.change = {
@@ -399,7 +397,7 @@
 
     test('rebase change fires reload event', done => {
       const eventStub = sinon.stub(element, 'dispatchEvent');
-      sinon.stub(element.$.restAPI, 'getResponseObject').returns(
+      stubRestApi('getResponseObject').returns(
           Promise.resolve({}));
       element._handleResponse({__key: 'rebase'}, {});
       flush(() => {
@@ -429,24 +427,21 @@
       });
     });
 
-    test('two dialogs are not shown at the same time', done => {
+    test('two dialogs are not shown at the same time', async () => {
       element._hasKnownChainState = true;
-      flush(() => {
-        const rebaseButton = element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebase"]');
-        assert.ok(rebaseButton);
-        MockInteractions.tap(rebaseButton);
-        flush();
-        assert.isFalse(element.$.confirmRebase.hidden);
-        sinon.stub(element.$.restAPI, 'getChanges')
-            .returns(Promise.resolve([]));
-        element._handleCherrypickTap();
-        flush(() => {
-          assert.isTrue(element.$.confirmRebase.hidden);
-          assert.isFalse(element.$.confirmCherrypick.hidden);
-          done();
-        });
-      });
+      await flush();
+      const rebaseButton = element.shadowRoot
+          .querySelector('gr-button[data-action-key="rebase"]');
+      assert.ok(rebaseButton);
+      MockInteractions.tap(rebaseButton);
+      await flush();
+      assert.isFalse(element.$.confirmRebase.hidden);
+      stubRestApi('getChanges')
+          .returns(Promise.resolve([]));
+      element._handleCherrypickTap();
+      await flush();
+      assert.isTrue(element.$.confirmRebase.hidden);
+      assert.isFalse(element.$.confirmCherrypick.hidden);
     });
 
     test('fullscreen-overlay-opened hides content', () => {
@@ -469,16 +464,16 @@
       assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
     });
 
-    test('_setLabelValuesOnRevert', () => {
-      const labels = {'Foo': 1, 'Bar-Baz': -2};
+    test('_setReviewOnRevert', () => {
+      const review = {labels: {'Foo': 1, 'Bar-Baz': -2}};
       const changeId = 1234;
-      sinon.stub(element.$.jsAPI, 'getLabelValuesPostRevert').returns(labels);
-      const saveStub = sinon.stub(element.$.restAPI, 'saveChangeReview')
+      sinon.stub(element.jsAPI, 'getReviewPostRevert').returns(review);
+      const saveStub = stubRestApi('saveChangeReview')
           .returns(Promise.resolve());
-      return element._setLabelValuesOnRevert(changeId).then(() => {
+      return element._setReviewOnRevert(changeId).then(() => {
         assert.isTrue(saveStub.calledOnce);
         assert.equal(saveStub.lastCall.args[0], changeId);
-        assert.deepEqual(saveStub.lastCall.args[2], {labels});
+        assert.deepEqual(saveStub.lastCall.args[2], review);
       });
     });
 
@@ -740,15 +735,15 @@
         const changes = [
           {
             change_id: '12345678901234', topic: 'T', subject: 'random',
-            project: 'A',
+            project: 'A', status: 'MERGED',
           },
           {
             change_id: '23456', topic: 'T', subject: 'a'.repeat(100),
-            project: 'B',
+            project: 'B', status: 'NEW',
           },
         ];
         setup(done => {
-          sinon.stub(element.$.restAPI, 'getChanges')
+          stubRestApi('getChanges')
               .returns(Promise.resolve(changes));
           element._handleCherrypickTap();
           flush(() => {
@@ -767,8 +762,8 @@
           flush(() => {
             const changesTable = dialog.shadowRoot.querySelector('table');
             const headers = Array.from(changesTable.querySelectorAll('th'));
-            const expectedHeadings = ['Change', 'Subject', 'Project',
-              'Status', ''];
+            const expectedHeadings = ['', 'Change', 'Status', 'Subject',
+              'Project', 'Progress', ''];
             const headings = headers.map(header => header.innerText);
             assert.equal(headings.length, expectedHeadings.length);
             for (let i = 0; i < headings.length; i++) {
@@ -777,7 +772,7 @@
             const changeRows = changesTable.querySelectorAll('tbody > tr');
             const change = Array.from(changeRows[0].querySelectorAll('td'))
                 .map(e => e.innerText);
-            const expectedChange = ['1234567890', 'random', 'A',
+            const expectedChange = ['', '1234567890', 'MERGED', 'random', 'A',
               'NOT STARTED', ''];
             for (let i = 0; i < change.length; i++) {
               assert.equal(change[i].trim(), expectedChange[i]);
@@ -994,7 +989,7 @@
         element.change = {
           current_revision: 'abc1234',
         };
-        sinon.stub(element.$.restAPI, 'getChanges')
+        stubRestApi('getChanges')
             .returns(Promise.resolve([
               {change_id: '12345678901234', topic: 'T', subject: 'random'},
               {change_id: '23456', topic: 'T', subject: 'a'.repeat(100)},
@@ -1019,7 +1014,7 @@
             submission_id: '199 0',
             current_revision: '2000',
           };
-          getChangesStub = sinon.stub(element.$.restAPI, 'getChanges')
+          getChangesStub = stubRestApi('getChanges')
               .returns(Promise.resolve([
                 {change_id: '12345678901234', topic: 'T', subject: 'random'},
                 {change_id: '23456', topic: 'T', subject: 'a'.repeat(100)},
@@ -1129,7 +1124,7 @@
             submission_id: '199',
             current_revision: '2000',
           };
-          sinon.stub(element.$.restAPI, 'getChanges')
+          stubRestApi('getChanges')
               .returns(Promise.resolve([
                 {change_id: '12345678901234', topic: 'T', subject: 'random'},
               ]));
@@ -1529,9 +1524,6 @@
       setup(() => {
         element.change = {
           current_revision: 'abc1234',
-        };
-        element.change = {
-          current_revision: 'abc1234',
           labels: {
             foo: {
               values: {
@@ -1578,6 +1570,16 @@
         assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
       });
 
+      test('not added when change is merged', () => {
+        element.change.status = ChangeStatus.MERGED;
+        flush(() => {
+          const approveButton =
+          element.shadowRoot
+              .querySelector('gr-button[data-action-key=\'review\']');
+          assert.isNull(approveButton);
+        });
+      });
+
       test('not added when already approved', () => {
         element.change = {
           current_revision: 'abc1234',
@@ -1717,6 +1719,87 @@
                 .querySelector('gr-button[data-action-key=\'review\']');
         assert.equal(approveButton.getAttribute('data-label'), 'bar+2');
       });
+
+      test('added when can approve an already-approved code review label',
+          () => {
+            element.change = {
+              current_revision: 'abc1234',
+              labels: {
+                'Code-Review': {
+                  approved: {},
+                  values: {
+                    ' 0': '',
+                    '+1': '',
+                    '+2': '',
+                  },
+                },
+              },
+              permitted_labels: {
+                'Code-Review': [' 0', '+1', '+2'],
+              },
+            };
+            flush();
+            const approveButton =
+                element.shadowRoot
+                    .querySelector('gr-button[data-action-key=\'review\']');
+            assert.isNotNull(approveButton);
+          });
+
+      test('not added when the user has already approved', () => {
+        const vote = {
+          ...createApproval(),
+          _account_id: 123,
+          name: 'name',
+          value: 2,
+        };
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            'Code-Review': {
+              approved: {},
+              values: {
+                ' 0': '',
+                '+1': '',
+                '+2': '',
+              },
+              all: [vote],
+            },
+          },
+          permitted_labels: {
+            'Code-Review': [' 0', '+1', '+2'],
+          },
+        };
+        flush();
+        const approveButton =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
+        assert.isNull(approveButton);
+      });
+
+      test('not added when user owns the change', () => {
+        element.change = {
+          current_revision: 'abc1234',
+          owner: createAccountWithId(123),
+          labels: {
+            'Code-Review': {
+              approved: {},
+              values: {
+                ' 0': '',
+                '+1': '',
+                '+2': '',
+              },
+            },
+          },
+          permitted_labels: {
+            'Code-Review': [' 0', '+1', '+2'],
+          },
+        };
+        flush();
+        const approveButton =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
+        assert.isNull(approveButton);
+      });
     });
 
     test('adds download revision action', () => {
@@ -1785,7 +1868,7 @@
         };
 
         test('succeed', () => {
-          sinon.stub(element.$.restAPI, 'getChange')
+          stubRestApi('getChange')
               .callsFake( makeGetChange(5));
           return element._waitForChangeReachable(123).then(success => {
             assert.isTrue(success);
@@ -1793,7 +1876,7 @@
         });
 
         test('fail', () => {
-          sinon.stub(element.$.restAPI, 'getChange')
+          stubRestApi('getChange')
               .callsFake( makeGetChange(6));
           return element._waitForChangeReachable(123).then(success => {
             assert.isFalse(success);
@@ -1830,16 +1913,16 @@
       suite('happy path', () => {
         let sendStub;
         setup(() => {
-          sinon.stub(element.$.restAPI, 'getChangeDetail')
+          stubRestApi('getChangeDetail')
               .returns(Promise.resolve({
                 ...createChange(),
                 // element has latest info
                 revisions: createRevisions(element.latestPatchNum),
                 messages: createChangeMessages(1),
               }));
-          sendStub = sinon.stub(element.$.restAPI, 'executeChangeAction')
+          sendStub = stubRestApi('executeChangeAction')
               .returns(Promise.resolve({}));
-          getResponseObjectStub = sinon.stub(element.$.restAPI,
+          getResponseObjectStub = stubRestApi(
               'getResponseObject');
           sinon.stub(GerritNav,
               'navigateToChange').returns(Promise.resolve(true));
@@ -1857,7 +1940,7 @@
           setup(() => {
             element.change.submission_id = '199';
             element.change.current_revision = '2000';
-            sinon.stub(element.$.restAPI, 'getChanges')
+            stubRestApi('getChanges')
                 .returns(Promise.resolve([
                   {change_id: '12345678901234', topic: 'T', subject: 'random'},
                   {change_id: '23456', topic: 'T', subject: 'a'.repeat(100)},
@@ -1949,14 +2032,14 @@
 
       suite('failure modes', () => {
         test('non-latest', () => {
-          sinon.stub(element.$.restAPI, 'getChangeDetail')
+          stubRestApi('getChangeDetail')
               .returns(Promise.resolve({
                 ...createChange(),
                 // new patchset was uploaded
                 revisions: createRevisions(element.latestPatchNum + 1),
                 messages: createChangeMessages(1),
               }));
-          const sendStub = sinon.stub(element.$.restAPI,
+          const sendStub = stubRestApi(
               'executeChangeAction');
 
           return element._send('DELETE', payload, '/endpoint', true, cleanup)
@@ -1969,14 +2052,14 @@
         });
 
         test('send fails', () => {
-          sinon.stub(element.$.restAPI, 'getChangeDetail')
+          stubRestApi('getChangeDetail')
               .returns(Promise.resolve({
                 ...createChange(),
                 // element has latest info
                 revisions: createRevisions(element.latestPatchNum),
                 messages: createChangeMessages(1),
               }));
-          const sendStub = sinon.stub(element.$.restAPI,
+          const sendStub = stubRestApi(
               'executeChangeAction').callsFake(
               (num, method, patchNum, endpoint, payload, onErr) => {
                 onErr();
@@ -2017,30 +2100,22 @@
     let changeRevisionActions;
 
     setup(() => {
-      stub('gr-rest-api-interface', {
-        getChangeRevisionActions() {
-          return Promise.resolve(changeRevisionActions);
-        },
-        send(method, url, payload) {
-          return Promise.reject(new Error('error'));
-        },
-        getProjectConfig() { return Promise.resolve({}); },
-      });
+      stubRestApi('getChangeRevisionActions').returns(
+          Promise.resolve(changeRevisionActions));
+      stubRestApi('send').returns(Promise.reject(new Error('error')));
+      stubRestApi('getProjectConfig').returns(Promise.resolve({}));
 
       sinon.stub(getPluginLoader(), 'awaitPluginsLoaded')
           .returns(Promise.resolve());
 
       element = basicFixture.instantiate();
       // getChangeRevisionActions is not called without
-      // set the following properies
+      // set the following properties
       element.change = {};
       element.changeNum = '42';
       element.latestPatchNum = '2';
 
-      sinon.stub(element.$.confirmCherrypick.$.restAPI,
-          'getRepoBranches').returns(Promise.resolve([]));
-      sinon.stub(element.$.confirmMove.$.restAPI,
-          'getRepoBranches').returns(Promise.resolve([]));
+      stubRestApi('getRepoBranches').returns(Promise.resolve([]));
       return element.reload();
     });
 
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.js
index e812657..e71c086 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.js
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.js
@@ -21,6 +21,7 @@
 import {resetPlugins} from '../../../test/test-utils.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 {stubRestApi} from '../../../test/test-utils.js';
 
 const testHtmlPlugin = document.createElement('dom-module');
 testHtmlPlugin.innerHTML = `
@@ -86,11 +87,9 @@
   }
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-      getLoggedIn() { return Promise.resolve(false); },
-      deleteVote() { return Promise.resolve({ok: true}); },
-    });
+    stubRestApi('getConfig').returns(Promise.resolve({}));
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    stubRestApi('deleteVote').returns(Promise.resolve({ok: true}));
   });
 
   teardown(() => {
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 f8a5940..68e2368 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
@@ -28,7 +28,6 @@
 import '../../shared/gr-limited-text/gr-limited-text';
 import '../../shared/gr-linked-chip/gr-linked-chip';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-change-requirements/gr-change-requirements';
 import '../gr-commit-info/gr-commit-info';
 import '../gr-reviewer-list/gr-reviewer-list';
@@ -51,10 +50,6 @@
 import {changeIsOpen} from '../../../utils/change-util';
 import {customElement, property, observe} from '@polymer/decorators';
 import {
-  EditRevisionInfo,
-  ParsedChangeInfo,
-} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
-import {
   AccountDetailInfo,
   AccountInfo,
   BranchName,
@@ -73,9 +68,17 @@
   TopicName,
 } from '../../../types/common';
 import {assertNever} from '../../../utils/common-util';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GrEditableLabel} from '../../shared/gr-editable-label/gr-editable-label';
 import {GrLinkedChip} from '../../shared/gr-linked-chip/gr-linked-chip';
+import {appContext} from '../../../services/app-context';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {
+  Metadata,
+  isSectionSet,
+  DisplayRules,
+} from '../../../utils/change-metadata-util';
+import {fireEvent} from '../../../utils/event-util';
+import {EditRevisionInfo, ParsedChangeInfo} from '../../../types/types';
 
 const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
 
@@ -102,7 +105,7 @@
 
 const NOT_CURRENT_MESSAGE = 'Not current - rebase possible';
 
-interface PushCertifacteValidationInfo {
+interface PushCertificateValidationInfo {
   class: string;
   icon: string;
   message: string;
@@ -110,7 +113,7 @@
 
 export interface GrChangeMetadata {
   $: {
-    restAPI: RestApiService & Element;
+    webLinks: HTMLElement;
   };
 }
 
@@ -171,7 +174,7 @@
     type: Object,
     computed: '_computePushCertificateValidation(serverConfig, change)',
   })
-  _pushCertificateValidation: PushCertifacteValidationInfo | null = null;
+  _pushCertificateValidation?: PushCertificateValidationInfo;
 
   @property({type: Boolean, computed: '_computeShowRequirements(change)'})
   _showRequirements = false;
@@ -194,9 +197,32 @@
   @property({type: Object})
   _CHANGE_ROLE = ChangeRole;
 
+  @property({type: Object})
+  _SECTION = Metadata;
+
+  @property({type: Boolean})
+  _showAllSections = false;
+
+  @property({type: Boolean})
+  _isNewChangeSummaryUiEnabled = false;
+
+  flagsService = appContext.flagsService;
+
+  restApiService = appContext.restApiService;
+
+  private readonly reporting = appContext.reportingService;
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._isNewChangeSummaryUiEnabled = this.flagsService.isEnabled(
+      KnownExperimentId.NEW_CHANGE_SUMMARY_UI
+    );
+  }
+
   @observe('change.labels')
   _labelsChanged(labels?: LabelNameToInfoMap) {
-    this.labels = {...labels} || null;
+    this.labels = {...labels};
   }
 
   @observe('change')
@@ -223,13 +249,13 @@
         return;
       }
       this.set(['change', 'assignee'], acct);
-      this.$.restAPI.setAssignee(this.change._number, acct._account_id);
+      this.restApiService.setAssignee(this.change._number, acct._account_id);
     } else {
       if (!this.change.assignee) {
         return;
       }
       this.set(['change', 'assignee'], undefined);
-      this.$.restAPI.deleteAssignee(this.change._number);
+      this.restApiService.deleteAssignee(this.change._number);
     }
   }
 
@@ -238,7 +264,7 @@
   }
 
   /**
-   * @return If array is empty, returns null instead so
+   * @return If array is empty, returns undefined instead so
    * an existential check can be used to hide or show the webLinks
    * section.
    */
@@ -246,9 +272,7 @@
     commitInfo?: CommitInfoWithRequiredCommit,
     serverConfig?: ServerInfo
   ) {
-    if (!commitInfo) {
-      return null;
-    }
+    if (!commitInfo) return undefined;
     const weblinks = GerritNav.getChangeWeblinks(
       this.change ? this.change.project : ('' as RepoName),
       commitInfo.commit,
@@ -257,15 +281,11 @@
         config: serverConfig,
       }
     );
-    return weblinks.length ? weblinks : null;
+    return weblinks.length ? weblinks : undefined;
   }
 
   _isAssigneeEnabled(serverConfig?: ServerInfo) {
-    return (
-      serverConfig &&
-      serverConfig.change &&
-      !!serverConfig.change.enable_assignee
-    );
+    return !!serverConfig?.change?.enable_assignee;
   }
 
   _computeStrategy(change?: ParsedChangeInfo) {
@@ -285,54 +305,43 @@
       throw new Error('change must be set');
     }
     const lastTopic = this.change.topic;
-    const topic = e.detail.length ? e.detail : null;
+    const topic = e.detail.length ? e.detail : undefined;
     this._settingTopic = true;
     const topicChangedForChangeNumber = this.change._number;
-    this.$.restAPI
+    this.restApiService
       .setChangeTopic(topicChangedForChangeNumber, topic)
       .then(newTopic => {
-        if (
-          !this.change ||
-          this.change._number !== topicChangedForChangeNumber
-        ) {
-          return;
-        }
+        if (this.change?._number !== topicChangedForChangeNumber) return;
         this._settingTopic = false;
         this.set(['change', 'topic'], newTopic);
         if (newTopic !== lastTopic) {
-          this.dispatchEvent(
-            new CustomEvent('topic-changed', {bubbles: true, composed: true})
-          );
+          fireEvent(this, 'topic-changed');
         }
       });
   }
 
   _showAddTopic(
-    changeRecord: ElementPropertyDeepChange<GrChangeMetadata, 'change'>,
+    changeRecord?: ElementPropertyDeepChange<GrChangeMetadata, 'change'>,
     settingTopic?: boolean
   ) {
-    const hasTopic =
-      !!changeRecord && !!changeRecord.base && !!changeRecord.base.topic;
+    const hasTopic = !!changeRecord?.base?.topic;
     return !hasTopic && !settingTopic;
   }
 
   _showTopicChip(
-    changeRecord: ElementPropertyDeepChange<GrChangeMetadata, 'change'>,
+    changeRecord?: ElementPropertyDeepChange<GrChangeMetadata, 'change'>,
     settingTopic?: boolean
   ) {
-    const hasTopic =
-      !!changeRecord && !!changeRecord.base && !!changeRecord.base.topic;
+    const hasTopic = !!changeRecord?.base?.topic;
     return hasTopic && !settingTopic;
   }
 
   _showCherryPickOf(
-    changeRecord: ElementPropertyDeepChange<GrChangeMetadata, 'change'>
+    changeRecord?: ElementPropertyDeepChange<GrChangeMetadata, 'change'>
   ) {
     const hasCherryPickOf =
-      !!changeRecord &&
-      !!changeRecord.base &&
-      !!changeRecord.base.cherry_pick_of_change &&
-      !!changeRecord.base.cherry_pick_of_patch_set;
+      !!changeRecord?.base?.cherry_pick_of_change &&
+      !!changeRecord?.base?.cherry_pick_of_patch_set;
     return hasCherryPickOf;
   }
 
@@ -345,47 +354,24 @@
     }
     const newHashtag = this._newHashtag;
     this._newHashtag = '' as Hashtag;
-    this.$.restAPI
+    this.restApiService
       .setChangeHashtag(this.change._number, {add: [newHashtag]})
       .then(newHashtag => {
         this.set(['change', 'hashtags'], newHashtag);
-        this.dispatchEvent(
-          new CustomEvent('hashtag-changed', {
-            bubbles: true,
-            composed: true,
-          })
-        );
+        fireEvent(this, 'hashtag-changed');
       });
   }
 
   _computeTopicReadOnly(mutable?: boolean, change?: ParsedChangeInfo) {
-    return (
-      !mutable ||
-      !change ||
-      !change.actions ||
-      !change.actions.topic ||
-      !change.actions.topic.enabled
-    );
+    return !mutable || !change?.actions?.topic?.enabled;
   }
 
   _computeHashtagReadOnly(mutable?: boolean, change?: ParsedChangeInfo) {
-    return (
-      !mutable ||
-      !change ||
-      !change.actions ||
-      !change.actions.hashtags ||
-      !change.actions.hashtags.enabled
-    );
+    return !mutable || !change?.actions?.hashtags?.enabled;
   }
 
   _computeAssigneeReadOnly(mutable?: boolean, change?: ParsedChangeInfo) {
-    return (
-      !mutable ||
-      !change ||
-      !change.actions ||
-      !change.actions.assignee ||
-      !change.actions.assignee.enabled
-    );
+    return !mutable || !change?.actions?.assignee?.enabled;
   }
 
   _computeTopicPlaceholder(_topicReadOnly?: boolean) {
@@ -419,17 +405,11 @@
   _computePushCertificateValidation(
     serverConfig?: ServerInfo,
     change?: ParsedChangeInfo
-  ): PushCertifacteValidationInfo | null {
-    if (
-      !change ||
-      !serverConfig ||
-      !serverConfig.receive ||
-      !serverConfig.receive.enable_signed_push
-    ) {
-      return null;
-    }
+  ): PushCertificateValidationInfo | undefined {
+    if (!change || !serverConfig?.receive?.enable_signed_push) return undefined;
+
     const rev = change.revisions[change.current_revision];
-    if (!rev.push_certificate || !rev.push_certificate.key) {
+    if (!rev.push_certificate?.key) {
       return {
         class: 'help',
         icon: 'gr-icons:help',
@@ -472,7 +452,7 @@
   }
 
   _problems(msg: string, key: GpgKeyInfo) {
-    if (!key || !key.problems || key.problems.length === 0) {
+    if (!key?.problems || key.problems.length === 0) {
       return msg;
     }
 
@@ -524,18 +504,15 @@
     }
     const target = (dom(e) as EventApi).rootTarget as GrLinkedChip;
     target.disabled = true;
-    this.$.restAPI
-      .setChangeTopic(this.change._number, null)
+    this.restApiService
+      .setChangeTopic(this.change._number)
       .then(() => {
         target.disabled = false;
         this.set(['change', 'topic'], '');
-        this.dispatchEvent(
-          new CustomEvent('topic-changed', {bubbles: true, composed: true})
-        );
+        fireEvent(this, 'topic-changed');
       })
       .catch(() => {
         target.disabled = false;
-        return;
       });
   }
 
@@ -546,7 +523,7 @@
     }
     const target = (dom(e) as EventApi).rootTarget as GrLinkedChip;
     target.disabled = true;
-    this.$.restAPI
+    this.restApiService
       .setChangeHashtag(this.change._number, {remove: [target.text as Hashtag]})
       .then(newHashtags => {
         target.disabled = false;
@@ -554,35 +531,59 @@
       })
       .catch(() => {
         target.disabled = false;
-        return;
       });
   }
 
   _computeIsWip(change?: ParsedChangeInfo) {
-    return change && !!change.work_in_progress;
+    return !!change?.work_in_progress;
   }
 
   _computeShowRoleClass(change?: ParsedChangeInfo, role?: ChangeRole) {
     return this._getNonOwnerRole(change, role) ? '' : 'hideDisplay';
   }
 
+  _computeDisplayState(
+    showAllSections: boolean,
+    change: ParsedChangeInfo | undefined,
+    section: Metadata
+  ) {
+    if (
+      !this._isNewChangeSummaryUiEnabled ||
+      showAllSections ||
+      DisplayRules.ALWAYS_SHOW.includes(section) ||
+      (DisplayRules.SHOW_IF_SET.includes(section) &&
+        isSectionSet(section, change))
+    ) {
+      return '';
+    }
+    return 'hideDisplay';
+  }
+
+  _computeShowAllLabelText(showAllSections: boolean) {
+    if (showAllSections) {
+      return 'Show less';
+    } else {
+      return 'Show all';
+    }
+  }
+
+  _onShowAllClick() {
+    this._showAllSections = !this._showAllSections;
+    this.reporting.reportInteraction('toggle show all button', {
+      sectionName: 'metadata',
+      toState: this._showAllSections ? 'Show all' : 'Show less',
+    });
+  }
+
   /**
-   * Get the user with the specified role on the change. Returns null if the
+   * Get the user with the specified role on the change. Returns undefined if the
    * user with that role is the same as the owner.
    */
   _getNonOwnerRole(change?: ParsedChangeInfo, role?: ChangeRole) {
-    if (
-      !change ||
-      !change.current_revision ||
-      !change.revisions[change.current_revision]
-    ) {
-      return null;
-    }
+    if (!change?.revisions?.[change.current_revision]) return undefined;
 
     const rev = change.revisions[change.current_revision];
-    if (!rev) {
-      return null;
-    }
+    if (!rev) return undefined;
 
     if (
       role === ChangeRole.UPLOADER &&
@@ -611,7 +612,7 @@
       return rev.commit.committer;
     }
 
-    return null;
+    return undefined;
   }
 
   _computeParents(
@@ -670,7 +671,7 @@
       return undefined;
     }
     const provider = GrReviewerSuggestionsProvider.create(
-      this.$.restAPI,
+      this.restApiService,
       change._number,
       SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY
     );
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
index f1d1127..59bf8ad 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
@@ -49,7 +49,15 @@
       pointer-events: none;
     }
     .hashtagChip {
-      margin-bottom: var(--spacing-m);
+      padding-bottom: var(--spacing-s);
+    }
+    /* consistent with section .title, .value */
+    .hashtagChip.new-change-summary-true:not(last-of-type) {
+      padding-bottom: var(--spacing-s);
+    }
+    .hashtagChip.new-change-summary-true:last-of-type {
+      display: inline;
+      vertical-align: top;
     }
     #externalStyle {
       display: block;
@@ -70,7 +78,7 @@
     }
     .icon.help,
     .icon.notTrusted {
-      color: #ffa62f;
+      color: var(--warning-foreground);
     }
     .icon.invalid {
       color: var(--negative-red-text-color);
@@ -79,7 +87,7 @@
       color: var(--positive-green-text-color);
     }
     .parentList.notCurrent.nonMerge #parentNotCurrentMessage {
-      --arrow-color: #ffa62f;
+      --arrow-color: var(--warning-foreground);
       display: inline-block;
     }
     .separatedSection {
@@ -91,21 +99,76 @@
       --linked-chip-text-color: var(--link-color);
     }
     gr-reviewer-list {
-      --account-max-length: 120px;
+      --account-max-length: 100px;
       max-width: 285px;
     }
+    .metadata-title {
+      font-weight: var(--font-weight-bold);
+      color: var(--deemphasized-text-color);
+      padding-left: var(--metadata-horizontal-padding);
+    }
+    .metadata-header {
+      display: flex;
+      justify-content: space-between;
+      align-items: flex-end;
+      /* The goal is to achieve alignment of the owner account chip and the
+         commit message box. Their top border should be on the same line. */
+      margin-bottom: var(--spacing-s);
+    }
+    .show-all-button iron-icon {
+      color: inherit;
+      --iron-icon-height: 18px;
+      --iron-icon-width: 18px;
+    }
   </style>
   <gr-external-style id="externalStyle" name="change-metadata">
-    <section>
+    <template is="dom-if" if="[[_isNewChangeSummaryUiEnabled]]">
+      <div class="metadata-header">
+        <h3 class="metadata-title">Change Info</h3>
+        <gr-button link="" class="show-all-button" on-click="_onShowAllClick"
+          >[[_computeShowAllLabelText(_showAllSections)]]
+          <iron-icon
+            icon="gr-icons:expand-more"
+            hidden$="[[_showAllSections]]"
+          ></iron-icon
+          ><iron-icon
+            icon="gr-icons:expand-less"
+            hidden$="[[!_showAllSections]]"
+          ></iron-icon>
+        </gr-button>
+      </div>
+    </template>
+    <template is="dom-if" if="[[_isNewChangeSummaryUiEnabled]]">
+      <template is="dom-if" if="[[change.submitted]]">
+        <section
+          class$="[[_computeDisplayState(_showAllSections, change, _SECTION.SUBMITTED)]]"
+        >
+          <span class="title">Submitted</span>
+          <span class="value">
+            <gr-date-formatter
+              has-tooltip=""
+              date-str="[[change.submitted]]"
+              show-yesterday=""
+            ></gr-date-formatter>
+          </span>
+        </section>
+      </template>
+    </template>
+    <section
+      class$="[[_computeDisplayState(_showAllSections, change, _SECTION.UPDATED)]]"
+    >
       <span class="title">Updated</span>
       <span class="value">
         <gr-date-formatter
           has-tooltip=""
           date-str="[[change.updated]]"
+          show-yesterday=""
         ></gr-date-formatter>
       </span>
     </section>
-    <section>
+    <section
+      class$="[[_computeDisplayState(_showAllSections, change, _SECTION.OWNER)]]"
+    >
       <span class="title">Owner</span>
       <span class="value">
         <gr-account-chip
@@ -156,7 +219,9 @@
       </span>
     </section>
     <template is="dom-if" if="[[_isAssigneeEnabled(serverConfig)]]">
-      <section class="assignee">
+      <section
+        class$="assignee [[_computeDisplayState(_showAllSections, change, _SECTION.ASSIGNEE)]]"
+      >
         <span class="title">Assignee</span>
         <span class="value">
           <gr-account-list
@@ -172,7 +237,9 @@
         </span>
       </section>
     </template>
-    <section>
+    <section
+      class$="[[_computeDisplayState(_showAllSections, change, _SECTION.REVIEWERS)]]"
+    >
       <span class="title">Reviewers</span>
       <span class="value">
         <gr-reviewer-list
@@ -183,7 +250,9 @@
         ></gr-reviewer-list>
       </span>
     </section>
-    <section>
+    <section
+      class$="[[_computeDisplayState(_showAllSections, change, _SECTION.CC)]]"
+    >
       <span class="title">CC</span>
       <span class="value">
         <gr-reviewer-list
@@ -198,7 +267,9 @@
       is="dom-if"
       if="[[_computeShowRepoBranchTogether(change.project, change.branch)]]"
     >
-      <section>
+      <section
+        class$="[[_computeDisplayState(_showAllSections, change, _SECTION.REPO_BRANCH)]]"
+      >
         <span class="title">Repo | Branch</span>
         <span class="value">
           <a href$="[[_computeProjectUrl(change.project)]]"
@@ -215,7 +286,9 @@
       is="dom-if"
       if="[[!_computeShowRepoBranchTogether(change.project, change.branch)]]"
     >
-      <section>
+      <section
+        class$="[[_computeDisplayState(_showAllSections, change, _SECTION.REPO_BRANCH)]]"
+      >
         <span class="title">Repo</span>
         <span class="value">
           <a href$="[[_computeProjectUrl(change.project)]]">
@@ -226,7 +299,9 @@
           </a>
         </span>
       </section>
-      <section>
+      <section
+        class$="[[_computeDisplayState(_showAllSections, change, _SECTION.REPO_BRANCH)]]"
+      >
         <span class="title">Branch</span>
         <span class="value">
           <a href$="[[_computeBranchUrl(change.project, change.branch)]]">
@@ -238,7 +313,9 @@
         </span>
       </section>
     </template>
-    <section>
+    <section
+      class$="[[_computeDisplayState(_showAllSections, change, _SECTION.PARENT)]]"
+    >
       <span class="title">[[_computeParentsLabel(_currentParents)]]</span>
       <span class="value">
         <ol
@@ -262,7 +339,9 @@
         </ol>
       </span>
     </section>
-    <section class="topic">
+    <section
+      class$="topic [[_computeDisplayState(_showAllSections, change, _SECTION.TOPIC)]]"
+    >
       <span class="title">Topic</span>
       <span class="value">
         <template is="dom-if" if="[[_showTopicChip(change.*, _settingTopic)]]">
@@ -283,12 +362,15 @@
             placeholder="[[_computeTopicPlaceholder(_topicReadOnly)]]"
             read-only="[[_topicReadOnly]]"
             on-changed="_handleTopicChanged"
+            show-as-edit-pencil="[[_isNewChangeSummaryUiEnabled]]"
           ></gr-editable-label>
         </template>
       </span>
     </section>
     <template is="dom-if" if="[[_showCherryPickOf(change.*)]]">
-      <section>
+      <section
+        class$="[[_computeDisplayState(_showAllSections, change, _SECTION.CHERRY_PICK_OF)]]"
+      >
         <span class="title">Cherry pick of</span>
         <span class="value">
           <a
@@ -304,19 +386,21 @@
       </section>
     </template>
     <section
-      class="strategy"
+      class$="strategy [[_computeDisplayState(_showAllSections, change, _SECTION.STRATEGY)]]"
       hidden$="[[_computeHideStrategy(change)]]"
       hidden=""
     >
       <span class="title">Strategy</span>
       <span class="value">[[_computeStrategy(change)]]</span>
     </section>
-    <section class="hashtag">
+    <section
+      class$="hashtag [[_computeDisplayState(_showAllSections, change, _SECTION.HASHTAGS)]]"
+    >
       <span class="title">Hashtags</span>
       <span class="value">
         <template is="dom-repeat" items="[[change.hashtags]]">
           <gr-linked-chip
-            class="hashtagChip"
+            class$="hashtagChip new-change-summary-[[_isNewChangeSummaryUiEnabled]]"
             text="[[item]]"
             href="[[_computeHashtagUrl(item)]]"
             removable="[[!_hashtagReadOnly]]"
@@ -333,12 +417,15 @@
             placeholder="[[_computeHashtagPlaceholder(_hashtagReadOnly)]]"
             read-only="[[_hashtagReadOnly]]"
             on-changed="_handleHashtagChanged"
+            show-as-edit-pencil="[[_isNewChangeSummaryUiEnabled]]"
           ></gr-editable-label>
         </template>
       </span>
     </section>
     <div class="separatedSection">
-      <h3 class="assistive-tech-only">Label Scores</h3>
+      <template is="dom-if" if="[[!_isNewChangeSummaryUiEnabled]]">
+        <h3 class="assistive-tech-only">Label Scores</h3>
+      </template>
       <gr-change-requirements
         change="{{change}}"
         account="[[account]]"
@@ -371,5 +458,4 @@
       ></gr-endpoint-param>
     </gr-endpoint-decorator>
   </gr-external-style>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
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
deleted file mode 100644
index 5bdf105..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.js
+++ /dev/null
@@ -1,782 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import '../../core/gr-router/gr-router.js';
-import './gr-change-metadata.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';
-
-const basicFixture = fixtureFromElement('gr-change-metadata');
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-change-metadata tests', () => {
-  let element;
-
-  setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-      getLoggedIn() { return Promise.resolve(false); },
-    });
-
-    element = basicFixture.instantiate();
-  });
-
-  test('computed fields', () => {
-    assert.isFalse(element._computeHideStrategy({status: 'NEW'}));
-    assert.isTrue(element._computeHideStrategy({status: 'MERGED'}));
-    assert.isTrue(element._computeHideStrategy({status: 'ABANDONED'}));
-    assert.equal(element._computeStrategy({submit_type: 'CHERRY_PICK'}),
-        'Cherry Pick');
-    assert.equal(element._computeStrategy({submit_type: 'REBASE_ALWAYS'}),
-        'Rebase Always');
-  });
-
-  test('computed fields requirements', () => {
-    assert.isFalse(element._computeShowRequirements({status: 'MERGED'}));
-    assert.isFalse(element._computeShowRequirements({status: 'ABANDONED'}));
-
-    // No labels and no requirements: submit status is useless
-    assert.isFalse(element._computeShowRequirements({
-      status: 'NEW',
-      labels: {},
-    }));
-
-    // Work in Progress: submit status should be present
-    assert.isTrue(element._computeShowRequirements({
-      status: 'NEW',
-      labels: {},
-      work_in_progress: true,
-    }));
-
-    // We have at least one reason to display Submit Status
-    assert.isTrue(element._computeShowRequirements({
-      status: 'NEW',
-      labels: {
-        Verified: {
-          approved: false,
-        },
-      },
-      requirements: [],
-    }));
-    assert.isTrue(element._computeShowRequirements({
-      status: 'NEW',
-      labels: {},
-      requirements: [{
-        fallback_text: 'Resolve all comments',
-        status: 'OK',
-      }],
-    }));
-  });
-
-  test('show strategy for open change', () => {
-    element.change = {status: 'NEW', submit_type: 'CHERRY_PICK', labels: {}};
-    flush();
-    const strategy = element.shadowRoot
-        .querySelector('.strategy');
-    assert.ok(strategy);
-    assert.isFalse(strategy.hasAttribute('hidden'));
-    assert.equal(strategy.children[1].innerHTML, 'Cherry Pick');
-  });
-
-  test('hide strategy for closed change', () => {
-    element.change = {status: 'MERGED', labels: {}};
-    flush();
-    assert.isTrue(element.shadowRoot
-        .querySelector('.strategy').hasAttribute('hidden'));
-  });
-
-  test('weblinks use GerritNav interface', () => {
-    const weblinksStub = sinon.stub(GerritNav, '_generateWeblinks')
-        .returns([{name: 'stubb', url: '#s'}]);
-    element.commitInfo = {};
-    element.serverConfig = {};
-    flush();
-    const webLinks = element.$.webLinks;
-    assert.isTrue(weblinksStub.called);
-    assert.isFalse(webLinks.hasAttribute('hidden'));
-    assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
-  });
-
-  test('weblinks hidden when no weblinks', () => {
-    element.commitInfo = {};
-    element.serverConfig = {};
-    flush();
-    const webLinks = element.$.webLinks;
-    assert.isTrue(webLinks.hasAttribute('hidden'));
-  });
-
-  test('weblinks hidden when only gitiles weblink', () => {
-    element.commitInfo = {web_links: [{name: 'gitiles', url: '#'}]};
-    element.serverConfig = {};
-    flush();
-    const webLinks = element.$.webLinks;
-    assert.isTrue(webLinks.hasAttribute('hidden'));
-    assert.equal(element._computeWebLinks(element.commitInfo), null);
-  });
-
-  test('weblinks hidden when sole weblink is set as primary', () => {
-    const browser = 'browser';
-    element.commitInfo = {web_links: [{name: browser, url: '#'}]};
-    element.serverConfig = {
-      gerrit: {
-        primary_weblink_name: browser,
-      },
-    };
-    flush();
-    const webLinks = element.$.webLinks;
-    assert.isTrue(webLinks.hasAttribute('hidden'));
-  });
-
-  test('weblinks are visible when other weblinks', () => {
-    const router = document.createElement('gr-router');
-    sinon.stub(GerritNav, '_generateWeblinks').callsFake(
-        router._generateWeblinks.bind(router));
-
-    element.commitInfo = {web_links: [{name: 'test', url: '#'}]};
-    flush();
-    const webLinks = element.$.webLinks;
-    assert.isFalse(webLinks.hasAttribute('hidden'));
-    assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
-    // With two non-gitiles weblinks, there are two returned.
-    element.commitInfo = {
-      web_links: [{name: 'test', url: '#'}, {name: 'test2', url: '#'}]};
-    assert.equal(element._computeWebLinks(element.commitInfo).length, 2);
-  });
-
-  test('weblinks are visible when gitiles and other weblinks', () => {
-    const router = document.createElement('gr-router');
-    sinon.stub(GerritNav, '_generateWeblinks').callsFake(
-        router._generateWeblinks.bind(router));
-
-    element.commitInfo = {
-      web_links: [{name: 'test', url: '#'}, {name: 'gitiles', url: '#'}]};
-    flush();
-    const webLinks = element.$.webLinks;
-    assert.isFalse(webLinks.hasAttribute('hidden'));
-    // Only the non-gitiles weblink is returned.
-    assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
-  });
-
-  suite('_getNonOwnerRole', () => {
-    let change;
-
-    setup(() => {
-      change = {
-        owner: {
-          email: 'abc@def',
-          _account_id: 1019328,
-        },
-        revisions: {
-          rev1: {
-            _number: 1,
-            uploader: {
-              email: 'ghi@def',
-              _account_id: 1011123,
-            },
-            commit: {
-              author: {email: 'jkl@def'},
-              committer: {email: 'ghi@def'},
-            },
-          },
-        },
-        current_revision: 'rev1',
-      };
-    });
-
-    suite('role=uploader', () => {
-      test('_getNonOwnerRole for uploader', () => {
-        assert.deepEqual(
-            element._getNonOwnerRole(change, element._CHANGE_ROLE.UPLOADER),
-            {email: 'ghi@def', _account_id: 1011123});
-      });
-
-      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.isNotOk(element._getNonOwnerRole(change,
-            element._CHANGE_ROLE.UPLOADER));
-      });
-
-      test('_getNonOwnerRole null for uploader with no current rev', () => {
-        delete change.current_revision;
-        assert.isNotOk(element._getNonOwnerRole(change,
-            element._CHANGE_ROLE.UPLOADER));
-      });
-
-      test('_computeShowRoleClass show uploader', () => {
-        assert.equal(element._computeShowRoleClass(
-            change, element._CHANGE_ROLE.UPLOADER), '');
-      });
-
-      test('_computeShowRoleClass hide uploader', () => {
-        // Set the uploader email to be the same as the owner.
-        change.revisions.rev1.uploader._account_id = 1019328;
-        assert.equal(element._computeShowRoleClass(change,
-            element._CHANGE_ROLE.UPLOADER), 'hideDisplay');
-      });
-    });
-
-    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.isNotOk(element._getNonOwnerRole(change,
-            element._CHANGE_ROLE.COMMITTER));
-      });
-
-      test('_getNonOwnerRole null for committer with no current rev', () => {
-        delete change.current_revision;
-        assert.isNotOk(element._getNonOwnerRole(change,
-            element._CHANGE_ROLE.COMMITTER));
-      });
-
-      test('_getNonOwnerRole null for committer with no commit', () => {
-        delete change.revisions.rev1.commit;
-        assert.isNotOk(element._getNonOwnerRole(change,
-            element._CHANGE_ROLE.COMMITTER));
-      });
-
-      test('_getNonOwnerRole null for committer with no committer', () => {
-        delete change.revisions.rev1.commit.committer;
-        assert.isNotOk(element._getNonOwnerRole(change,
-            element._CHANGE_ROLE.COMMITTER));
-      });
-    });
-
-    suite('role=author', () => {
-      test('_getNonOwnerRole for author', () => {
-        assert.deepEqual(
-            element._getNonOwnerRole(change, element._CHANGE_ROLE.AUTHOR),
-            {email: 'jkl@def'});
-      });
-
-      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.isNotOk(element._getNonOwnerRole(change,
-            element._CHANGE_ROLE.AUTHOR));
-      });
-
-      test('_getNonOwnerRole null for author with no current rev', () => {
-        delete change.current_revision;
-        assert.isNotOk(element._getNonOwnerRole(change,
-            element._CHANGE_ROLE.AUTHOR));
-      });
-
-      test('_getNonOwnerRole null for author with no commit', () => {
-        delete change.revisions.rev1.commit;
-        assert.isNotOk(element._getNonOwnerRole(change,
-            element._CHANGE_ROLE.AUTHOR));
-      });
-
-      test('_getNonOwnerRole null for author with no author', () => {
-        delete change.revisions.rev1.commit.author;
-        assert.isNotOk(element._getNonOwnerRole(change,
-            element._CHANGE_ROLE.AUTHOR));
-      });
-    });
-  });
-
-  test('Push Certificate Validation test BAD', () => {
-    const serverConfig = {
-      receive: {
-        enable_signed_push: true,
-      },
-    };
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      owner: {
-        _account_id: 1019328,
-      },
-      revisions: {
-        rev1: {
-          _number: 1,
-          push_certificate: {
-            key: {
-              status: 'BAD',
-              problems: [
-                'No public keys found for key ID E5E20E52',
-              ],
-            },
-          },
-        },
-      },
-      current_revision: 'rev1',
-      status: 'NEW',
-      labels: {},
-      mergeable: true,
-    };
-    const result =
-        element._computePushCertificateValidation(serverConfig, change);
-    assert.equal(result.message,
-        'Push certificate is invalid:\n' +
-        'No public keys found for key ID E5E20E52');
-    assert.equal(result.icon, 'gr-icons:close');
-    assert.equal(result.class, 'invalid');
-  });
-
-  test('Push Certificate Validation test TRUSTED', () => {
-    const serverConfig = {
-      receive: {
-        enable_signed_push: true,
-      },
-    };
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      owner: {
-        _account_id: 1019328,
-      },
-      revisions: {
-        rev1: {
-          _number: 1,
-          push_certificate: {
-            key: {
-              status: 'TRUSTED',
-            },
-          },
-        },
-      },
-      current_revision: 'rev1',
-      status: 'NEW',
-      labels: {},
-      mergeable: true,
-    };
-    const result =
-        element._computePushCertificateValidation(serverConfig, change);
-    assert.equal(result.message,
-        'Push certificate is valid and key is trusted');
-    assert.equal(result.icon, 'gr-icons:check');
-    assert.equal(result.class, 'trusted');
-  });
-
-  test('Push Certificate Validation is missing test', () => {
-    const serverConfig = {
-      receive: {
-        enable_signed_push: true,
-      },
-    };
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      owner: {
-        _account_id: 1019328,
-      },
-      revisions: {
-        rev1: {
-          _number: 1,
-        },
-      },
-      current_revision: 'rev1',
-      status: 'NEW',
-      labels: {},
-      mergeable: true,
-    };
-    const result =
-        element._computePushCertificateValidation(serverConfig, change);
-    assert.equal(result.message,
-        'This patch set was created without a push certificate');
-    assert.equal(result.icon, 'gr-icons:help');
-    assert.equal(result.class, 'help');
-  });
-
-  test('_computeParents', () => {
-    const parents = [{commit: '123', subject: 'abc'}];
-    const revision = {commit: {parents}};
-    assert.deepEqual(element._computeParents({}, {}), []);
-    assert.equal(element._computeParents(null, revision), parents);
-    const change = current_revision => {
-      return {current_revision, revisions: {456: revision}};
-    };
-    assert.deepEqual(element._computeParents(change(null), null), []);
-    const change_bad_revision = change('789');
-    assert.deepEqual(element._computeParents(change_bad_revision, {}), []);
-    const change_no_commit = {current_revision: '456', revisions: {456: {}}};
-    assert.deepEqual(element._computeParents(change_no_commit, null), []);
-    const change_good = change('456');
-    assert.equal(element._computeParents(change_good, null), parents);
-  });
-
-  test('_currentParents', () => {
-    const revision = parent => {
-      return {commit: {parents: [{commit: parent, subject: 'abc'}]}};
-    };
-    element.change = {
-      current_revision: '456',
-      revisions: {456: revision('111')},
-      owner: {},
-    };
-    element.revision = revision('222');
-    assert.equal(element._currentParents[0].commit, '222');
-    element.revision = revision('333');
-    assert.equal(element._currentParents[0].commit, '333');
-    element.revision = null;
-    assert.equal(element._currentParents[0].commit, '111');
-    element.change = {current_revision: null};
-    assert.deepEqual(element._currentParents, []);
-  });
-
-  test('_computeParentsLabel', () => {
-    const parent = {commit: 'abc123', subject: 'My parent commit'};
-    assert.equal(element._computeParentsLabel([parent]), 'Parent');
-    assert.equal(element._computeParentsLabel([parent, parent]),
-        'Parents');
-  });
-
-  test('_computeParentListClass', () => {
-    const parent = {commit: 'abc123', subject: 'My parent commit'};
-    assert.equal(element._computeParentListClass([parent], true),
-        'parentList nonMerge current');
-    assert.equal(element._computeParentListClass([parent], false),
-        'parentList nonMerge notCurrent');
-    assert.equal(element._computeParentListClass([parent, parent], false),
-        'parentList merge notCurrent');
-    assert.equal(element._computeParentListClass([parent, parent], true),
-        'parentList merge current');
-  });
-
-  test('_showAddTopic', () => {
-    assert.isTrue(element._showAddTopic(null, false));
-    assert.isTrue(element._showAddTopic({base: {topic: null}}, false));
-    assert.isFalse(element._showAddTopic({base: {topic: null}}, true));
-    assert.isFalse(element._showAddTopic({base: {topic: 'foo'}}, true));
-    assert.isFalse(element._showAddTopic({base: {topic: 'foo'}}, false));
-  });
-
-  test('_showTopicChip', () => {
-    assert.isFalse(element._showTopicChip(null, false));
-    assert.isFalse(element._showTopicChip({base: {topic: null}}, false));
-    assert.isFalse(element._showTopicChip({base: {topic: null}}, true));
-    assert.isFalse(element._showTopicChip({base: {topic: 'foo'}}, true));
-    assert.isTrue(element._showTopicChip({base: {topic: 'foo'}}, false));
-  });
-
-  test('_showCherryPickOf', () => {
-    assert.isFalse(element._showCherryPickOf(null));
-    assert.isFalse(element._showCherryPickOf({
-      base: {
-        cherry_pick_of_change: null,
-        cherry_pick_of_patch_set: null,
-      },
-    }));
-    assert.isTrue(element._showCherryPickOf({
-      base: {
-        cherry_pick_of_change: 123,
-        cherry_pick_of_patch_set: 1,
-      },
-    }));
-  });
-
-  suite('Topic removal', () => {
-    let change;
-    setup(() => {
-      change = {
-        _number: 'the number',
-        actions: {
-          topic: {enabled: false},
-        },
-        change_id: 'the id',
-        topic: 'the topic',
-        status: 'NEW',
-        submit_type: 'CHERRY_PICK',
-        labels: {
-          test: {
-            all: [{_account_id: 1, name: 'bojack', value: 1}],
-            default_value: 0,
-            values: [],
-          },
-        },
-        removable_reviewers: [],
-      };
-    });
-
-    test('_computeTopicReadOnly', () => {
-      let mutable = false;
-      assert.isTrue(element._computeTopicReadOnly(mutable, change));
-      mutable = true;
-      assert.isTrue(element._computeTopicReadOnly(mutable, change));
-      change.actions.topic.enabled = true;
-      assert.isFalse(element._computeTopicReadOnly(mutable, change));
-      mutable = false;
-      assert.isTrue(element._computeTopicReadOnly(mutable, change));
-    });
-
-    test('topic read only hides delete button', () => {
-      element.account = {};
-      element.change = change;
-      flush();
-      const button = element.shadowRoot
-          .querySelector('gr-linked-chip').shadowRoot
-          .querySelector('gr-button');
-      assert.isTrue(button.hasAttribute('hidden'));
-    });
-
-    test('topic not read only does not hide delete button', () => {
-      element.account = {test: true};
-      change.actions.topic.enabled = true;
-      element.change = change;
-      flush();
-      const button = element.shadowRoot
-          .querySelector('gr-linked-chip').shadowRoot
-          .querySelector('gr-button');
-      assert.isFalse(button.hasAttribute('hidden'));
-    });
-  });
-
-  suite('Hashtag removal', () => {
-    let change;
-    setup(() => {
-      change = {
-        _number: 'the number',
-        actions: {
-          hashtags: {enabled: false},
-        },
-        change_id: 'the id',
-        hashtags: ['test-hashtag'],
-        status: 'NEW',
-        submit_type: 'CHERRY_PICK',
-        labels: {
-          test: {
-            all: [{_account_id: 1, name: 'bojack', value: 1}],
-            default_value: 0,
-            values: [],
-          },
-        },
-        removable_reviewers: [],
-      };
-    });
-
-    test('_computeHashtagReadOnly', () => {
-      flush();
-      let mutable = false;
-      assert.isTrue(element._computeHashtagReadOnly(mutable, change));
-      mutable = true;
-      assert.isTrue(element._computeHashtagReadOnly(mutable, change));
-      change.actions.hashtags.enabled = true;
-      assert.isFalse(element._computeHashtagReadOnly(mutable, change));
-      mutable = false;
-      assert.isTrue(element._computeHashtagReadOnly(mutable, change));
-    });
-
-    test('hashtag read only hides delete button', () => {
-      flush();
-      element.account = {};
-      element.change = change;
-      flush();
-      const button = element.shadowRoot
-          .querySelector('gr-linked-chip').shadowRoot
-          .querySelector('gr-button');
-      assert.isTrue(button.hasAttribute('hidden'));
-    });
-
-    test('hashtag not read only does not hide delete button', () => {
-      flush();
-      element.account = {test: true};
-      change.actions.hashtags.enabled = true;
-      element.change = change;
-      flush();
-      const button = element.shadowRoot
-          .querySelector('gr-linked-chip').shadowRoot
-          .querySelector('gr-button');
-      assert.isFalse(button.hasAttribute('hidden'));
-    });
-  });
-
-  suite('remove reviewer votes', () => {
-    setup(() => {
-      sinon.stub(element, '_computeTopicReadOnly').returns(true);
-      element.change = {
-        _number: 42,
-        change_id: 'the id',
-        actions: [],
-        topic: 'the topic',
-        status: 'NEW',
-        submit_type: 'CHERRY_PICK',
-        labels: {
-          test: {
-            all: [{_account_id: 1, name: 'bojack', value: 1}],
-            default_value: 0,
-            values: [],
-          },
-        },
-        removable_reviewers: [],
-      };
-      flush();
-    });
-
-    suite('assignee field', () => {
-      const dummyAccount = {
-        _account_id: 1,
-        name: 'bojack',
-      };
-      const change = {
-        actions: {
-          assignee: {enabled: false},
-        },
-        assignee: dummyAccount,
-      };
-      let deleteStub;
-      let setStub;
-
-      setup(() => {
-        deleteStub = sinon.stub(element.$.restAPI, 'deleteAssignee');
-        setStub = sinon.stub(element.$.restAPI, 'setAssignee');
-        element.serverConfig = {
-          change: {
-            enable_assignee: true,
-          },
-        };
-      });
-
-      test('changing change recomputes _assignee', () => {
-        assert.isFalse(!!element._assignee.length);
-        const change = element.change;
-        change.assignee = dummyAccount;
-        element._changeChanged(change);
-        assert.deepEqual(element._assignee[0], dummyAccount);
-      });
-
-      test('modifying _assignee calls API', () => {
-        assert.isFalse(!!element._assignee.length);
-        element.set('_assignee', [dummyAccount]);
-        assert.isTrue(setStub.calledOnce);
-        assert.deepEqual(element.change.assignee, dummyAccount);
-        element.set('_assignee', [dummyAccount]);
-        assert.isTrue(setStub.calledOnce);
-        element.set('_assignee', []);
-        assert.isTrue(deleteStub.calledOnce);
-        assert.equal(element.change.assignee, undefined);
-        element.set('_assignee', []);
-        assert.isTrue(deleteStub.calledOnce);
-      });
-
-      test('_computeAssigneeReadOnly', () => {
-        let mutable = false;
-        assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
-        mutable = true;
-        assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
-        change.actions.assignee.enabled = true;
-        assert.isFalse(element._computeAssigneeReadOnly(mutable, change));
-        mutable = false;
-        assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
-      });
-    });
-
-    test('changing topic', () => {
-      const newTopic = 'the new topic';
-      sinon.stub(element.$.restAPI, 'setChangeTopic').returns(
-          Promise.resolve(newTopic));
-      element._handleTopicChanged({detail: newTopic});
-      const topicChangedSpy = sinon.spy();
-      element.addEventListener('topic-changed', topicChangedSpy);
-      assert.isTrue(element.$.restAPI.setChangeTopic.calledWith(
-          42, newTopic));
-      return element.$.restAPI.setChangeTopic.lastCall.returnValue
-          .then(() => {
-            assert.equal(element.change.topic, newTopic);
-            assert.isTrue(topicChangedSpy.called);
-          });
-    });
-
-    test('topic removal', () => {
-      sinon.stub(element.$.restAPI, 'setChangeTopic').returns(
-          Promise.resolve());
-      const chip = element.shadowRoot
-          .querySelector('gr-linked-chip');
-      const remove = chip.$.remove;
-      const topicChangedSpy = sinon.spy();
-      element.addEventListener('topic-changed', topicChangedSpy);
-      MockInteractions.tap(remove);
-      assert.isTrue(chip.disabled);
-      assert.isTrue(element.$.restAPI.setChangeTopic.calledWith(
-          42, null));
-      return element.$.restAPI.setChangeTopic.lastCall.returnValue
-          .then(() => {
-            assert.isFalse(chip.disabled);
-            assert.equal(element.change.topic, '');
-            assert.isTrue(topicChangedSpy.called);
-          });
-    });
-
-    test('changing hashtag', () => {
-      flush();
-      element._newHashtag = 'new hashtag';
-      const newHashtag = ['new hashtag'];
-      sinon.stub(element.$.restAPI, 'setChangeHashtag').returns(
-          Promise.resolve(newHashtag));
-      element._handleHashtagChanged({}, 'new hashtag');
-      assert.isTrue(element.$.restAPI.setChangeHashtag.calledWith(
-          42, {add: ['new hashtag']}));
-      return element.$.restAPI.setChangeHashtag.lastCall.returnValue
-          .then(() => {
-            assert.equal(element.change.hashtags, newHashtag);
-          });
-    });
-  });
-
-  test('editTopic', () => {
-    element.account = {test: true};
-    element.change = {actions: {topic: {enabled: true}}};
-    flush();
-
-    const label = element.shadowRoot
-        .querySelector('.topicEditableLabel');
-    assert.ok(label);
-    sinon.stub(label, 'open');
-    element.editTopic();
-    flush();
-
-    assert.isTrue(label.open.called);
-  });
-
-  suite('plugin endpoints', () => {
-    test('endpoint params', done => {
-      element.change = {labels: {}};
-      element.revision = {};
-      let hookEl;
-      let plugin;
-      pluginApi.install(
-          p => {
-            plugin = p;
-            plugin.hook('change-metadata-item').getLastAttached()
-                .then(el => hookEl = el);
-          },
-          '0.1',
-          'http://some/plugins/url.html');
-      getPluginLoader().loadPlugins([]);
-      flush(() => {
-        assert.strictEqual(hookEl.plugin, plugin);
-        assert.strictEqual(hookEl.change, element.change);
-        assert.strictEqual(hookEl.revision, element.revision);
-        done();
-      });
-    });
-  });
-});
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
new file mode 100644
index 0000000..59287b2
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
@@ -0,0 +1,952 @@
+/**
+ * @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 '../../core/gr-router/gr-router';
+import './gr-change-metadata';
+import {GerritNav} 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 {GrChangeMetadata} from './gr-change-metadata';
+import {
+  createServerInfo,
+  createUserConfig,
+  createParsedChange,
+  createAccountWithId,
+  createRequirement,
+  createCommitInfoWithRequiredCommit,
+  createWebLinkInfo,
+  createGerritInfo,
+  createGitPerson,
+  createCommit,
+  createRevision,
+  createAccountDetailWithId,
+  createChangeConfig,
+} from '../../../test/test-data-generators';
+import {
+  ChangeStatus,
+  SubmitType,
+  RequirementStatus,
+  GpgKeyInfoStatus,
+} from '../../../constants/constants';
+import {
+  EmailAddress,
+  AccountId,
+  CommitId,
+  ServerInfo,
+  RevisionInfo,
+  ParentCommitInfo,
+  TopicName,
+  ElementPropertyDeepChange,
+  PatchSetNum,
+  NumericChangeId,
+  LabelValueToDescriptionMap,
+  Hashtag,
+} from '../../../types/common';
+import {SinonStubbedMember} from 'sinon/pkg/sinon-esm';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
+import {GrEditableLabel} from '../../shared/gr-editable-label/gr-editable-label';
+import {PluginApi} from '../../../api/plugin';
+import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import {stubRestApi} from '../../../test/test-utils.js';
+import {ParsedChangeInfo} from '../../../types/types';
+
+const basicFixture = fixtureFromElement('gr-change-metadata');
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-change-metadata tests', () => {
+  let element: GrChangeMetadata;
+
+  setup(() => {
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    stubRestApi('getConfig').returns(
+      Promise.resolve({
+        ...createServerInfo(),
+        user: {
+          ...createUserConfig(),
+          anonymous_coward_name: 'test coward name',
+        },
+      })
+    );
+    element = basicFixture.instantiate();
+  });
+
+  test('computed fields', () => {
+    assert.isFalse(
+      element._computeHideStrategy({
+        ...createParsedChange(),
+        status: ChangeStatus.NEW,
+      })
+    );
+    assert.isTrue(
+      element._computeHideStrategy({
+        ...createParsedChange(),
+        status: ChangeStatus.MERGED,
+      })
+    );
+    assert.isTrue(
+      element._computeHideStrategy({
+        ...createParsedChange(),
+        status: ChangeStatus.ABANDONED,
+      })
+    );
+    assert.equal(
+      element._computeStrategy({
+        ...createParsedChange(),
+        submit_type: SubmitType.CHERRY_PICK,
+      }),
+      'Cherry Pick'
+    );
+    assert.equal(
+      element._computeStrategy({
+        ...createParsedChange(),
+        submit_type: SubmitType.REBASE_ALWAYS,
+      }),
+      'Rebase Always'
+    );
+  });
+
+  test('computed fields requirements', () => {
+    assert.isFalse(
+      element._computeShowRequirements({
+        ...createParsedChange(),
+        status: ChangeStatus.MERGED,
+      })
+    );
+    assert.isFalse(
+      element._computeShowRequirements({
+        ...createParsedChange(),
+        status: ChangeStatus.ABANDONED,
+      })
+    );
+
+    // No labels and no requirements: submit status is useless
+    assert.isFalse(
+      element._computeShowRequirements({
+        ...createParsedChange(),
+        status: ChangeStatus.NEW,
+        labels: {},
+      })
+    );
+
+    // Work in Progress: submit status should be present
+    assert.isTrue(
+      element._computeShowRequirements({
+        ...createParsedChange(),
+        status: ChangeStatus.NEW,
+        labels: {},
+        work_in_progress: true,
+      })
+    );
+
+    // We have at least one reason to display Submit Status
+    assert.isTrue(
+      element._computeShowRequirements({
+        ...createParsedChange(),
+        status: ChangeStatus.NEW,
+        labels: {
+          Verified: {
+            approved: createAccountWithId(),
+          },
+        },
+        requirements: [],
+      })
+    );
+    assert.isTrue(
+      element._computeShowRequirements({
+        ...createParsedChange(),
+        status: ChangeStatus.NEW,
+        labels: {},
+        requirements: [
+          {
+            ...createRequirement(),
+            fallbackText: 'Resolve all comments',
+            status: RequirementStatus.OK,
+          },
+        ],
+      })
+    );
+  });
+
+  test('show strategy for open change', () => {
+    element.change = {
+      ...createParsedChange(),
+      status: ChangeStatus.NEW,
+      submit_type: SubmitType.CHERRY_PICK,
+      labels: {},
+    };
+    flush();
+    const strategy = element.shadowRoot?.querySelector('.strategy');
+    assert.ok(strategy);
+    assert.isFalse(strategy?.hasAttribute('hidden'));
+    assert.equal(strategy?.children[1].innerHTML, 'Cherry Pick');
+  });
+
+  test('hide strategy for closed change', () => {
+    element.change = {
+      ...createParsedChange(),
+      status: ChangeStatus.MERGED,
+      labels: {},
+    };
+    flush();
+    assert.isTrue(
+      element.shadowRoot?.querySelector('.strategy')?.hasAttribute('hidden')
+    );
+  });
+
+  test('weblinks use GerritNav interface', () => {
+    const weblinksStub = sinon
+      .stub(GerritNav, '_generateWeblinks')
+      .returns([{name: 'stubb', url: '#s'}]);
+    element.commitInfo = createCommitInfoWithRequiredCommit();
+    element.serverConfig = createServerInfo();
+    flush();
+    const webLinks = element.$.webLinks;
+    assert.isTrue(weblinksStub.called);
+    assert.isFalse(webLinks.hasAttribute('hidden'));
+    assert.equal(element._computeWebLinks(element.commitInfo)?.length, 1);
+  });
+
+  test('weblinks hidden when no weblinks', () => {
+    element.commitInfo = createCommitInfoWithRequiredCommit();
+    element.serverConfig = createServerInfo();
+    flush();
+    const webLinks = element.$.webLinks;
+    assert.isTrue(webLinks.hasAttribute('hidden'));
+  });
+
+  test('weblinks hidden when only gitiles weblink', () => {
+    element.commitInfo = {
+      ...createCommitInfoWithRequiredCommit(),
+      web_links: [{...createWebLinkInfo(), name: 'gitiles', url: '#'}],
+    };
+    element.serverConfig = createServerInfo();
+    flush();
+    const webLinks = element.$.webLinks;
+    assert.isTrue(webLinks.hasAttribute('hidden'));
+    assert.equal(element._computeWebLinks(element.commitInfo), null);
+  });
+
+  test('weblinks hidden when sole weblink is set as primary', () => {
+    const browser = 'browser';
+    element.commitInfo = {
+      ...createCommitInfoWithRequiredCommit(),
+      web_links: [{...createWebLinkInfo(), name: browser, url: '#'}],
+    };
+    element.serverConfig = {
+      ...createServerInfo(),
+      gerrit: {
+        ...createGerritInfo(),
+        primary_weblink_name: browser,
+      },
+    };
+    flush();
+    const webLinks = element.$.webLinks;
+    assert.isTrue(webLinks.hasAttribute('hidden'));
+  });
+
+  test('weblinks are visible when other weblinks', () => {
+    const router = document.createElement('gr-router');
+    sinon
+      .stub(GerritNav, '_generateWeblinks')
+      .callsFake(router._generateWeblinks.bind(router));
+
+    element.commitInfo = {
+      ...createCommitInfoWithRequiredCommit(),
+      web_links: [{...createWebLinkInfo(), name: 'test', url: '#'}],
+    };
+    flush();
+    const webLinks = element.$.webLinks;
+    assert.isFalse(webLinks.hasAttribute('hidden'));
+    assert.equal(element._computeWebLinks(element.commitInfo)?.length, 1);
+    // With two non-gitiles weblinks, there are two returned.
+    element.commitInfo = {
+      ...createCommitInfoWithRequiredCommit(),
+      web_links: [
+        {...createWebLinkInfo(), name: 'test', url: '#'},
+        {...createWebLinkInfo(), name: 'test2', url: '#'},
+      ],
+    };
+    assert.equal(element._computeWebLinks(element.commitInfo)?.length, 2);
+  });
+
+  test('weblinks are visible when gitiles and other weblinks', () => {
+    const router = document.createElement('gr-router');
+    sinon
+      .stub(GerritNav, '_generateWeblinks')
+      .callsFake(router._generateWeblinks.bind(router));
+
+    element.commitInfo = {
+      ...createCommitInfoWithRequiredCommit(),
+      web_links: [
+        {...createWebLinkInfo(), name: 'test', url: '#'},
+        {...createWebLinkInfo(), name: 'gitiles', url: '#'},
+      ],
+    };
+    flush();
+    const webLinks = element.$.webLinks;
+    assert.isFalse(webLinks.hasAttribute('hidden'));
+    // Only the non-gitiles weblink is returned.
+    assert.equal(element._computeWebLinks(element.commitInfo)?.length, 1);
+  });
+
+  suite('_getNonOwnerRole', () => {
+    let change: ParsedChangeInfo | undefined;
+
+    setup(() => {
+      change = {
+        ...createParsedChange(),
+        owner: {
+          ...createAccountWithId(),
+          email: 'abc@def' as EmailAddress,
+          _account_id: 1019328 as AccountId,
+        },
+        revisions: {
+          rev1: {
+            ...createRevision(),
+            uploader: {
+              ...createAccountWithId(),
+              email: 'ghi@def' as EmailAddress,
+              _account_id: 1011123 as AccountId,
+            },
+            commit: {
+              ...createCommit(),
+              author: {...createGitPerson(), email: 'jkl@def'},
+              committer: {...createGitPerson(), email: 'ghi@def'},
+            },
+          },
+        },
+        current_revision: 'rev1' as CommitId,
+      };
+    });
+
+    suite('role=uploader', () => {
+      test('_getNonOwnerRole for uploader', () => {
+        assert.deepEqual(
+          element._getNonOwnerRole(change, element._CHANGE_ROLE.UPLOADER),
+          {
+            ...createAccountWithId(),
+            email: 'ghi@def' as EmailAddress,
+            _account_id: 1011123 as AccountId,
+          }
+        );
+      });
+
+      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 as AccountId;
+        assert.isNotOk(
+          element._getNonOwnerRole(change, element._CHANGE_ROLE.UPLOADER)
+        );
+      });
+
+      test('_computeShowRoleClass show uploader', () => {
+        assert.equal(
+          element._computeShowRoleClass(change, element._CHANGE_ROLE.UPLOADER),
+          ''
+        );
+      });
+
+      test('_computeShowRoleClass hide uploader', () => {
+        // Set the uploader email to be the same as the owner.
+        change!.revisions.rev1.uploader!._account_id = 1019328 as AccountId;
+        assert.equal(
+          element._computeShowRoleClass(change, element._CHANGE_ROLE.UPLOADER),
+          'hideDisplay'
+        );
+      });
+    });
+
+    suite('role=committer', () => {
+      test('_getNonOwnerRole for committer', () => {
+        change!.revisions.rev1.uploader!.email = 'ghh@def' as EmailAddress;
+        assert.deepEqual(
+          element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER),
+          {...createGitPerson(), 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.isNotOk(
+          element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER)
+        );
+      });
+
+      test('_getNonOwnerRole null for committer with no commit', () => {
+        delete change!.revisions.rev1.commit;
+        assert.isNotOk(
+          element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER)
+        );
+      });
+    });
+
+    suite('role=author', () => {
+      test('_getNonOwnerRole for author', () => {
+        assert.deepEqual(
+          element._getNonOwnerRole(change, element._CHANGE_ROLE.AUTHOR),
+          {...createGitPerson(), email: 'jkl@def'}
+        );
+      });
+
+      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.isNotOk(
+          element._getNonOwnerRole(change, element._CHANGE_ROLE.AUTHOR)
+        );
+      });
+
+      test('_getNonOwnerRole null for author with no commit', () => {
+        delete change!.revisions.rev1.commit;
+        assert.isNotOk(
+          element._getNonOwnerRole(change, element._CHANGE_ROLE.AUTHOR)
+        );
+      });
+    });
+  });
+
+  suite('Push Certificate Validation', () => {
+    let serverConfig: ServerInfo | undefined;
+    let change: ParsedChangeInfo | undefined;
+
+    setup(() => {
+      serverConfig = {
+        ...createServerInfo(),
+        receive: {
+          enable_signed_push: 'true',
+        },
+      };
+      change = {
+        ...createParsedChange(),
+        revisions: {
+          rev1: {
+            ...createRevision(1),
+            push_certificate: {
+              certificate: 'Push certificate',
+              key: {
+                status: GpgKeyInfoStatus.BAD,
+                problems: ['No public keys found for key ID E5E20E52'],
+              },
+            },
+          },
+        },
+        current_revision: 'rev1' as CommitId,
+        status: ChangeStatus.NEW,
+        labels: {},
+        mergeable: true,
+      };
+    });
+
+    test('Push Certificate Validation test BAD', () => {
+      change!.revisions.rev1!.push_certificate = {
+        certificate: 'Push certificate',
+        key: {
+          status: GpgKeyInfoStatus.BAD,
+          problems: ['No public keys found for key ID E5E20E52'],
+        },
+      };
+      const result = element._computePushCertificateValidation(
+        serverConfig,
+        change
+      );
+      assert.equal(
+        result?.message,
+        'Push certificate is invalid:\n' +
+          'No public keys found for key ID E5E20E52'
+      );
+      assert.equal(result?.icon, 'gr-icons:close');
+      assert.equal(result?.class, 'invalid');
+    });
+
+    test('Push Certificate Validation test TRUSTED', () => {
+      change!.revisions.rev1!.push_certificate = {
+        certificate: 'Push certificate',
+        key: {
+          status: GpgKeyInfoStatus.TRUSTED,
+        },
+      };
+      const result = element._computePushCertificateValidation(
+        serverConfig,
+        change
+      );
+      assert.equal(
+        result?.message,
+        'Push certificate is valid and key is trusted'
+      );
+      assert.equal(result?.icon, 'gr-icons:check');
+      assert.equal(result?.class, 'trusted');
+    });
+
+    test('Push Certificate Validation is missing test', () => {
+      change!.revisions.rev1! = createRevision(1);
+      const result = element._computePushCertificateValidation(
+        serverConfig,
+        change
+      );
+      assert.equal(
+        result?.message,
+        'This patch set was created without a push certificate'
+      );
+      assert.equal(result?.icon, 'gr-icons:help');
+      assert.equal(result?.class, 'help');
+    });
+  });
+
+  test('_computeParents', () => {
+    const parents: ParentCommitInfo[] = [
+      {...createCommit(), commit: '123' as CommitId, subject: 'abc'},
+    ];
+    const revision: RevisionInfo = {
+      ...createRevision(1),
+      commit: {...createCommit(), parents},
+    };
+    assert.equal(element._computeParents(undefined, revision), parents);
+    const change = (current_revision: CommitId): ParsedChangeInfo => {
+      return {
+        ...createParsedChange(),
+        current_revision,
+        revisions: {456: revision},
+      };
+    };
+    const change_bad_revision = change('789' as CommitId);
+    assert.deepEqual(
+      element._computeParents(change_bad_revision, createRevision()),
+      []
+    );
+    const change_no_commit: ParsedChangeInfo = {
+      ...createParsedChange(),
+      current_revision: '456' as CommitId,
+      revisions: {456: createRevision()},
+    };
+    assert.deepEqual(element._computeParents(change_no_commit, undefined), []);
+    const change_good = change('456' as CommitId);
+    assert.equal(element._computeParents(change_good, undefined), parents);
+  });
+
+  test('_currentParents', () => {
+    const revision = (parent: CommitId): RevisionInfo => {
+      return {
+        ...createRevision(),
+        commit: {
+          ...createCommit(),
+          parents: [{...createCommit(), commit: parent, subject: 'abc'}],
+        },
+      };
+    };
+    element.change = {
+      ...createParsedChange(),
+      current_revision: '456' as CommitId,
+      revisions: {456: revision('111' as CommitId)},
+      owner: {},
+    };
+    element.revision = revision('222' as CommitId);
+    assert.equal(element._currentParents[0].commit, '222');
+    element.revision = revision('333' as CommitId);
+    assert.equal(element._currentParents[0].commit, '333');
+    element.revision = undefined;
+    assert.equal(element._currentParents[0].commit, '111');
+    element.change = createParsedChange();
+    assert.deepEqual(element._currentParents, []);
+  });
+
+  test('_computeParentsLabel', () => {
+    const parent: ParentCommitInfo = {
+      ...createCommit(),
+      commit: 'abc123' as CommitId,
+      subject: 'My parent commit',
+    };
+    assert.equal(element._computeParentsLabel([parent]), 'Parent');
+    assert.equal(element._computeParentsLabel([parent, parent]), 'Parents');
+  });
+
+  test('_computeParentListClass', () => {
+    const parent: ParentCommitInfo = {
+      ...createCommit(),
+      commit: 'abc123' as CommitId,
+      subject: 'My parent commit',
+    };
+    assert.equal(
+      element._computeParentListClass([parent], true),
+      'parentList nonMerge current'
+    );
+    assert.equal(
+      element._computeParentListClass([parent], false),
+      'parentList nonMerge notCurrent'
+    );
+    assert.equal(
+      element._computeParentListClass([parent, parent], false),
+      'parentList merge notCurrent'
+    );
+    assert.equal(
+      element._computeParentListClass([parent, parent], true),
+      'parentList merge current'
+    );
+  });
+
+  test('_showAddTopic', () => {
+    const changeRecord: ElementPropertyDeepChange<
+      GrChangeMetadata,
+      'change'
+    > = {
+      base: {...createParsedChange()},
+      path: '',
+      value: undefined,
+    };
+    assert.isTrue(element._showAddTopic(undefined, false));
+    assert.isTrue(element._showAddTopic(changeRecord, false));
+    assert.isFalse(element._showAddTopic(changeRecord, true));
+    changeRecord.base!.topic = 'foo' as TopicName;
+    assert.isFalse(element._showAddTopic(changeRecord, true));
+    assert.isFalse(element._showAddTopic(changeRecord, false));
+  });
+
+  test('_showTopicChip', () => {
+    const changeRecord: ElementPropertyDeepChange<
+      GrChangeMetadata,
+      'change'
+    > = {
+      base: {...createParsedChange()},
+      path: '',
+      value: undefined,
+    };
+    assert.isFalse(element._showTopicChip(undefined, false));
+    assert.isFalse(element._showTopicChip(changeRecord, false));
+    assert.isFalse(element._showTopicChip(changeRecord, true));
+    changeRecord.base!.topic = 'foo' as TopicName;
+    assert.isFalse(element._showTopicChip(changeRecord, true));
+    assert.isTrue(element._showTopicChip(changeRecord, false));
+  });
+
+  test('_showCherryPickOf', () => {
+    const changeRecord: ElementPropertyDeepChange<
+      GrChangeMetadata,
+      'change'
+    > = {
+      base: {...createParsedChange()},
+      path: '',
+      value: undefined,
+    };
+    assert.isFalse(element._showCherryPickOf(undefined));
+    assert.isFalse(element._showCherryPickOf(changeRecord));
+    changeRecord.base!.cherry_pick_of_change = 123 as NumericChangeId;
+    changeRecord.base!.cherry_pick_of_patch_set = 1 as PatchSetNum;
+    assert.isTrue(element._showCherryPickOf(changeRecord));
+  });
+
+  suite('Topic removal', () => {
+    let change: ParsedChangeInfo;
+    setup(() => {
+      change = {
+        ...createParsedChange(),
+        actions: {
+          topic: {enabled: false},
+        },
+        topic: 'the topic' as TopicName,
+        status: ChangeStatus.NEW,
+        submit_type: SubmitType.CHERRY_PICK,
+        labels: {
+          test: {
+            all: [{_account_id: 1 as AccountId, name: 'bojack', value: 1}],
+            default_value: 0,
+            values: ([] as unknown) as LabelValueToDescriptionMap,
+          },
+        },
+        removable_reviewers: [],
+      };
+    });
+
+    test('_computeTopicReadOnly', () => {
+      let mutable = false;
+      assert.isTrue(element._computeTopicReadOnly(mutable, change));
+      mutable = true;
+      assert.isTrue(element._computeTopicReadOnly(mutable, change));
+      change!.actions!.topic!.enabled = true;
+      assert.isFalse(element._computeTopicReadOnly(mutable, change));
+      mutable = false;
+      assert.isTrue(element._computeTopicReadOnly(mutable, change));
+    });
+
+    test('topic read only hides delete button', () => {
+      element.account = createAccountDetailWithId();
+      element.change = change;
+      flush();
+      const button = element!
+        .shadowRoot!.querySelector('gr-linked-chip')!
+        .shadowRoot!.querySelector('gr-button');
+      assert.isTrue(button?.hasAttribute('hidden'));
+    });
+
+    test('topic not read only does not hide delete button', () => {
+      element.account = createAccountDetailWithId();
+      change.actions!.topic!.enabled = true;
+      element.change = change;
+      flush();
+      const button = element!
+        .shadowRoot!.querySelector('gr-linked-chip')!
+        .shadowRoot!.querySelector('gr-button');
+      assert.isFalse(button?.hasAttribute('hidden'));
+    });
+  });
+
+  suite('Hashtag removal', () => {
+    let change: ParsedChangeInfo;
+    setup(() => {
+      change = {
+        ...createParsedChange(),
+        actions: {
+          hashtags: {enabled: false},
+        },
+        hashtags: ['test-hashtag' as Hashtag],
+        labels: {
+          test: {
+            all: [{_account_id: 1 as AccountId, name: 'bojack', value: 1}],
+            default_value: 0,
+            values: ([] as unknown) as LabelValueToDescriptionMap,
+          },
+        },
+        removable_reviewers: [],
+      };
+    });
+
+    test('_computeHashtagReadOnly', () => {
+      flush();
+      let mutable = false;
+      assert.isTrue(element._computeHashtagReadOnly(mutable, change));
+      mutable = true;
+      assert.isTrue(element._computeHashtagReadOnly(mutable, change));
+      change!.actions!.hashtags!.enabled = true;
+      assert.isFalse(element._computeHashtagReadOnly(mutable, change));
+      mutable = false;
+      assert.isTrue(element._computeHashtagReadOnly(mutable, change));
+    });
+
+    test('hashtag read only hides delete button', () => {
+      flush();
+      element.account = createAccountDetailWithId();
+      element.change = change;
+      flush();
+      const button = element!
+        .shadowRoot!.querySelector('gr-linked-chip')!
+        .shadowRoot!.querySelector('gr-button');
+      assert.isTrue(button?.hasAttribute('hidden'));
+    });
+
+    test('hashtag not read only does not hide delete button', () => {
+      flush();
+      element.account = createAccountDetailWithId();
+      change!.actions!.hashtags!.enabled = true;
+      element.change = change;
+      flush();
+      const button = element!
+        .shadowRoot!.querySelector('gr-linked-chip')!
+        .shadowRoot!.querySelector('gr-button');
+      assert.isFalse(button?.hasAttribute('hidden'));
+    });
+  });
+
+  suite('remove reviewer votes', () => {
+    setup(() => {
+      sinon.stub(element, '_computeTopicReadOnly').returns(true);
+      element.change = {
+        ...createParsedChange(),
+        topic: 'the topic' as TopicName,
+        labels: {
+          test: {
+            all: [{_account_id: 1 as AccountId, name: 'bojack', value: 1}],
+            default_value: 0,
+            values: ([] as unknown) as LabelValueToDescriptionMap,
+          },
+        },
+        removable_reviewers: [],
+      };
+      flush();
+    });
+
+    suite('assignee field', () => {
+      const dummyAccount = createAccountWithId();
+      const change: ParsedChangeInfo = {
+        ...createParsedChange(),
+        actions: {
+          assignee: {enabled: false},
+        },
+        assignee: dummyAccount,
+      };
+      let deleteStub: SinonStubbedMember<RestApiService['deleteAssignee']>;
+      let setStub: SinonStubbedMember<RestApiService['setAssignee']>;
+
+      setup(() => {
+        deleteStub = stubRestApi('deleteAssignee');
+        setStub = stubRestApi('setAssignee');
+        element.serverConfig = {
+          ...createServerInfo(),
+          change: {
+            ...createChangeConfig(),
+            enable_assignee: true,
+          },
+        };
+      });
+
+      test('changing change recomputes _assignee', () => {
+        assert.isFalse(!!element._assignee?.length);
+        const change = element.change;
+        change!.assignee = dummyAccount;
+        element._changeChanged(change);
+        assert.deepEqual(element?._assignee?.[0], dummyAccount);
+      });
+
+      test('modifying _assignee calls API', () => {
+        assert.isFalse(!!element._assignee?.length);
+        element.set('_assignee', [dummyAccount]);
+        assert.isTrue(setStub.calledOnce);
+        assert.deepEqual(element.change!.assignee, dummyAccount);
+        element.set('_assignee', [dummyAccount]);
+        assert.isTrue(setStub.calledOnce);
+        element.set('_assignee', []);
+        assert.isTrue(deleteStub.calledOnce);
+        assert.equal(element.change!.assignee, undefined);
+        element.set('_assignee', []);
+        assert.isTrue(deleteStub.calledOnce);
+      });
+
+      test('_computeAssigneeReadOnly', () => {
+        let mutable = false;
+        assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
+        mutable = true;
+        assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
+        change.actions!.assignee!.enabled = true;
+        assert.isFalse(element._computeAssigneeReadOnly(mutable, change));
+        mutable = false;
+        assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
+      });
+    });
+
+    test('changing topic', () => {
+      const newTopic = 'the new topic' as TopicName;
+      const setChangeTopicStub = stubRestApi('setChangeTopic').returns(
+        Promise.resolve(newTopic)
+      );
+      element._handleTopicChanged(new CustomEvent('test', {detail: newTopic}));
+      const topicChangedSpy = sinon.spy();
+      element.addEventListener('topic-changed', topicChangedSpy);
+      assert.isTrue(
+        setChangeTopicStub.calledWith(42 as NumericChangeId, newTopic)
+      );
+      return setChangeTopicStub.lastCall.returnValue.then(() => {
+        assert.equal(element.change!.topic, newTopic);
+        assert.isTrue(topicChangedSpy.called);
+      });
+    });
+
+    test('topic removal', () => {
+      const newTopic = 'the new topic' as TopicName;
+      const setChangeTopicStub = stubRestApi('setChangeTopic').returns(
+        Promise.resolve(newTopic)
+      );
+      const chip = element.shadowRoot!.querySelector('gr-linked-chip');
+      const remove = chip!.$.remove;
+      const topicChangedSpy = sinon.spy();
+      element.addEventListener('topic-changed', topicChangedSpy);
+      tap(remove);
+      assert.isTrue(chip?.disabled);
+      assert.isTrue(setChangeTopicStub.calledWith(42 as NumericChangeId));
+      return setChangeTopicStub.lastCall.returnValue.then(() => {
+        assert.isFalse(chip?.disabled);
+        assert.equal(element.change!.topic, '' as TopicName);
+        assert.isTrue(topicChangedSpy.called);
+      });
+    });
+
+    test('changing hashtag', () => {
+      flush();
+      element._newHashtag = 'new hashtag' as Hashtag;
+      const newHashtag: Hashtag[] = ['new hashtag' as Hashtag];
+      const setChangeHashtagStub = stubRestApi('setChangeHashtag').returns(
+        Promise.resolve(newHashtag)
+      );
+      element._handleHashtagChanged();
+      assert.isTrue(
+        setChangeHashtagStub.calledWith(42 as NumericChangeId, {
+          add: ['new hashtag' as Hashtag],
+        })
+      );
+      return setChangeHashtagStub.lastCall.returnValue.then(() => {
+        assert.equal(element.change!.hashtags, newHashtag);
+      });
+    });
+  });
+
+  test('editTopic', () => {
+    element.account = createAccountDetailWithId();
+    element.change = {
+      ...createParsedChange(),
+      actions: {topic: {enabled: true}},
+    };
+    flush();
+
+    const label = element.shadowRoot!.querySelector(
+      '.topicEditableLabel'
+    ) as GrEditableLabel;
+    assert.ok(label);
+    const openStub = sinon.stub(label, 'open');
+    element.editTopic();
+    flush();
+
+    assert.isTrue(openStub.called);
+  });
+
+  suite('plugin endpoints', () => {
+    test('endpoint params', done => {
+      element.change = createParsedChange();
+      element.revision = createRevision();
+      interface MetadataGrEndpointDecorator extends GrEndpointDecorator {
+        plugin: PluginApi;
+        change: ParsedChangeInfo;
+        revision: RevisionInfo;
+      }
+      let hookEl: MetadataGrEndpointDecorator;
+      let plugin: PluginApi;
+      pluginApi.install(
+        p => {
+          plugin = p;
+          plugin
+            .hook('change-metadata-item')
+            .getLastAttached()
+            .then(el => (hookEl = el as MetadataGrEndpointDecorator));
+        },
+        '0.1',
+        'http://some/plugins/url.html'
+      );
+      getPluginLoader().loadPlugins([]);
+      flush(() => {
+        assert.strictEqual(hookEl!.plugin, plugin);
+        assert.strictEqual(hookEl!.change, element.change);
+        assert.strictEqual(hookEl!.revision, element.revision);
+        done();
+      });
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
index cdac00a..e3fdf7a 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
@@ -34,8 +34,10 @@
   LabelNameToInfoMap,
   LabelInfo,
 } from '../../../types/common';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {appContext} from '../../../services/app-context';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {labelCompare} from '../../../utils/label-util';
 
 interface ChangeRequirement extends Requirement {
   satisfied: boolean;
@@ -49,6 +51,7 @@
 }
 
 interface Label {
+  labelName: string;
   labelInfo: LabelInfo;
   icon: string;
   style: string;
@@ -86,6 +89,13 @@
   @property({type: Boolean})
   _showOptionalLabels = true;
 
+  @property({type: Boolean})
+  _isNewChangeSummaryUiEnabled = appContext.flagsService.isEnabled(
+    KnownExperimentId.NEW_CHANGE_SUMMARY_UI
+  );
+
+  private readonly reporting = appContext.reportingService;
+
   _computeShowWip(change: ChangeInfo) {
     return change.work_in_progress;
   }
@@ -126,22 +136,19 @@
       LabelNameToInfoMap
     >
   ) {
-    const labels = labelsRecord.base;
-    this._optionalLabels = [];
-    this._requiredLabels = [];
+    const labels = labelsRecord.base || {};
+    const allLabels: Label[] = [];
 
-    for (const label of Object.keys(labels || {}).sort()) {
-      if (!hasOwnProperty(labels, label)) {
-        continue;
-      }
-
-      const labelInfo = labels[label];
-      const icon = this._computeLabelIcon(labelInfo);
-      const style = this._computeLabelClass(labelInfo);
-      const path = labelInfo.optional ? '_optionalLabels' : '_requiredLabels';
-
-      this.push(path, {label, icon, style, labelInfo});
+    for (const label of Object.keys(labels).sort(labelCompare)) {
+      allLabels.push({
+        labelName: label,
+        icon: this._computeLabelIcon(labels[label]),
+        style: this._computeLabelClass(labels[label]),
+        labelInfo: labels[label],
+      });
     }
+    this._optionalLabels = allLabels.filter(label => label.labelInfo.optional);
+    this._requiredLabels = allLabels.filter(label => !label.labelInfo.optional);
   }
 
   /**
@@ -188,11 +195,23 @@
 
   _handleShowHide() {
     this._showOptionalLabels = !this._showOptionalLabels;
+    this.reporting.reportInteraction('toggle show all button', {
+      sectionName: 'optional labels',
+      toState: this._showOptionalLabels ? 'Show all' : 'Show less',
+    });
   }
 
   _computeSubmitRequirementEndpoint(item: ChangeRequirement | ChangeWIP) {
     return `submit-requirement-item-${item.type}`;
   }
+
+  _computeShowAllLabelText(_showOptionalLabels: boolean) {
+    if (_showOptionalLabels) {
+      return 'Show less';
+    } else {
+      return 'Show all';
+    }
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
index ef71314..a502949 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
@@ -23,7 +23,7 @@
       width: 100%;
     }
     .status {
-      color: #ffa62f;
+      color: var(--warning-foreground);
       display: inline-block;
       text-align: center;
       vertical-align: top;
@@ -81,13 +81,27 @@
       color: var(--deemphasized-text-color);
       float: right;
     }
+    .show-all-button {
+      float: right;
+    }
     .spacer {
       height: var(--spacing-m);
     }
     gr-endpoint-param {
       display: none;
     }
+    .metadata-title {
+      font-weight: var(--font-weight-bold);
+      color: var(--deemphasized-text-color);
+      padding-left: var(--metadata-horizontal-padding);
+    }
+    .title .metadata-title {
+      padding-left: 0;
+    }
   </style>
+  <template is="dom-if" if="[[_isNewChangeSummaryUiEnabled]]">
+    <h3 class="metadata-title">Submit requirements</h3>
+  </template>
   <template is="dom-repeat" items="[[_requirements]]">
     <gr-endpoint-decorator
       class="submit-requirement-endpoints"
@@ -124,7 +138,7 @@
         <gr-limited-text
           class="name"
           limit="25"
-          text="[[item.label]]"
+          text="[[item.labelName]]"
         ></gr-limited-text>
       </div>
       <div class="value">
@@ -132,7 +146,7 @@
           change="{{change}}"
           account="[[account]]"
           mutable="[[mutable]]"
-          label="[[item.label]]"
+          label="[[item.labelName]]"
           label-info="[[item.labelInfo]]"
         ></gr-label-info>
       </div>
@@ -142,20 +156,42 @@
   <section
     class$="spacer [[_computeShowOptional(_optionalLabels.*)]]"
   ></section>
-  <section
-    show-bottom-border$="[[_showOptionalLabels]]"
-    on-click="_handleShowHide"
-    class$="showHide [[_computeShowOptional(_optionalLabels.*)]]"
-  >
-    <div class="title">Other labels</div>
-    <div class="value">
-      <iron-icon
-        id="showHide"
-        icon="[[_computeShowHideIcon(_showOptionalLabels)]]"
-      >
-      </iron-icon>
-    </div>
-  </section>
+  <template is="dom-if" if="[[_isNewChangeSummaryUiEnabled]]">
+    <section class$="showHide [[_computeShowOptional(_optionalLabels.*)]]">
+      <div class="title">
+        <h3 class="metadata-title">Other labels</h3>
+      </div>
+      <div class="value">
+        <gr-button link="" class="show-all-button" on-click="_handleShowHide"
+          >[[_computeShowAllLabelText(_showOptionalLabels)]]
+          <iron-icon
+            icon="gr-icons:expand-more"
+            hidden$="[[_showOptionalLabels]]"
+          ></iron-icon
+          ><iron-icon
+            icon="gr-icons:expand-less"
+            hidden$="[[!_showOptionalLabels]]"
+          ></iron-icon>
+        </gr-button>
+      </div>
+    </section>
+  </template>
+  <template is="dom-if" if="[[!_isNewChangeSummaryUiEnabled]]">
+    <section
+      show-bottom-border$="[[_showOptionalLabels]]"
+      on-click="_handleShowHide"
+      class$="showHide [[_computeShowOptional(_optionalLabels.*)]]"
+    >
+      <div class="title">Other labels</div>
+      <div class="value">
+        <iron-icon
+          id="showHide"
+          icon="[[_computeShowHideIcon(_showOptionalLabels)]]"
+        >
+        </iron-icon>
+      </div>
+    </section>
+  </template>
   <template is="dom-repeat" items="[[_optionalLabels]]">
     <section class$="optional [[_computeSectionClass(_showOptionalLabels)]]">
       <div class="title">
@@ -170,7 +206,7 @@
         <gr-limited-text
           class="name"
           limit="25"
-          text="[[item.label]]"
+          text="[[item.labelName]]"
         ></gr-limited-text>
       </div>
       <div class="value">
@@ -178,7 +214,7 @@
           change="{{change}}"
           account="[[account]]"
           mutable="[[mutable]]"
-          label="[[item.label]]"
+          label="[[item.labelName]]"
           label-info="[[item.labelInfo]]"
         ></gr-label-info>
       </div>
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js
index c2fc72d..fda8cb2 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js
@@ -75,7 +75,7 @@
     assert.equal(element._optionalLabels.length, 1);
     assert.equal(element._requiredLabels.length, 1);
 
-    assert.equal(element._optionalLabels[0].label, 'opt_test');
+    assert.equal(element._optionalLabels[0].labelName, 'opt_test');
     assert.equal(element._optionalLabels[0].icon, 'gr-icons:schedule');
     assert.equal(element._optionalLabels[0].style, '');
     assert.ok(element._optionalLabels[0].labelInfo);
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
new file mode 100644
index 0000000..427924e
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
@@ -0,0 +1,492 @@
+/**
+ * @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 {css, customElement, property} from 'lit-element';
+import {GrLitElement} from '../../lit/gr-lit-element';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {appContext} from '../../../services/app-context';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {
+  allRuns$,
+  aPluginHasRegistered$,
+  someProvidersAreLoading$,
+} from '../../../services/checks/checks-model';
+import {
+  Category,
+  CheckResult,
+  CheckRun,
+  Link,
+  RunStatus,
+} from '../../../api/checks';
+import {fireShowPrimaryTab} from '../../../utils/event-util';
+import '../../shared/gr-avatar/gr-avatar';
+import {
+  getResultsOf,
+  hasCompletedWithoutResults,
+  hasResultsOf,
+  iconForCategory,
+  iconForStatus,
+  isRunning,
+  isRunningOrHasCompleted,
+} from '../../../services/checks/checks-util';
+import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
+import {
+  CommentThread,
+  isResolved,
+  isUnresolved,
+  getFirstComment,
+  isRobotThread,
+  hasHumanReply,
+} from '../../../utils/comment-util';
+import {pluralize} from '../../../utils/string-util';
+import {AccountInfo} from '../../../types/common';
+import {notUndefined} from '../../../types/types';
+import {uniqueDefinedAvatar} from '../../../utils/account-util';
+import {PrimaryTab} from '../../../constants/constants';
+import {ChecksTabState, CommentTabState} from '../../../types/events';
+
+export enum SummaryChipStyles {
+  INFO = 'info',
+  WARNING = 'warning',
+  CHECK = 'check',
+  UNDEFINED = '',
+}
+
+@customElement('gr-summary-chip')
+export class GrSummaryChip extends GrLitElement {
+  @property()
+  icon = '';
+
+  @property()
+  styleType = SummaryChipStyles.UNDEFINED;
+
+  @property()
+  category?: CommentTabState;
+
+  private readonly reporting = appContext.reportingService;
+
+  static get styles() {
+    return [
+      sharedStyles,
+      css`
+        .summaryChip {
+          color: var(--chip-color);
+          cursor: pointer;
+          display: inline-block;
+          padding: var(--spacing-xxs) var(--spacing-m) var(--spacing-xxs)
+            var(--spacing-s);
+          margin-right: var(--spacing-s);
+          border-radius: 12px;
+          border: 1px solid gray;
+          vertical-align: top;
+        }
+        iron-icon {
+          width: var(--line-height-small);
+          height: var(--line-height-small);
+          vertical-align: top;
+        }
+        .summaryChip.warning {
+          border-color: var(--warning-foreground);
+          background-color: var(--warning-background);
+        }
+        .summaryChip.warning iron-icon {
+          color: var(--warning-foreground);
+        }
+        .summaryChip.check {
+          border-color: var(--gray-foreground);
+          background-color: var(--gray-background);
+        }
+        .summaryChip.check iron-icon {
+          color: var(--gray-foreground);
+        }
+      `,
+    ];
+  }
+
+  render() {
+    const chipClass = `summaryChip font-small ${this.styleType}`;
+    const grIcon = this.icon ? `gr-icons:${this.icon}` : '';
+    return html`<div
+      class="${chipClass}"
+      role="button"
+      @click="${this.handleClick}"
+    >
+      ${this.icon && html`<iron-icon icon="${grIcon}"></iron-icon>`}
+      <slot></slot>
+    </div>`;
+  }
+
+  private handleClick(e: MouseEvent) {
+    e.stopPropagation();
+    e.preventDefault();
+    this.reporting.reportInteraction('comment chip click', {
+      category: this.category,
+    });
+    fireShowPrimaryTab(this, PrimaryTab.COMMENT_THREADS, true, {
+      commentTab: this.category,
+    });
+  }
+}
+
+@customElement('gr-checks-chip')
+export class GrChecksChip extends GrLitElement {
+  @property()
+  icon = '';
+
+  @property()
+  text = '';
+
+  static get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: inline-block;
+        }
+        .checksChip {
+          color: var(--chip-color);
+          cursor: pointer;
+          display: inline-block;
+          margin-right: var(--spacing-s);
+          padding: var(--spacing-xxs) var(--spacing-m) var(--spacing-xxs)
+            var(--spacing-s);
+          border-radius: 12px;
+          border: 1px solid gray;
+          vertical-align: top;
+        }
+        .checksChip .text {
+          display: inline-block;
+          max-width: 120px;
+          white-space: nowrap;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          vertical-align: top;
+        }
+        iron-icon {
+          width: var(--line-height-small);
+          height: var(--line-height-small);
+          vertical-align: top;
+        }
+        .checksChip.error {
+          color: var(--error-foreground);
+          border-color: var(--error-foreground);
+          background-color: var(--error-background);
+        }
+        .checksChip.error iron-icon {
+          color: var(--error-foreground);
+        }
+        .checksChip.warning {
+          border-color: var(--warning-foreground);
+          background-color: var(--warning-background);
+        }
+        .checksChip.warning iron-icon {
+          color: var(--warning-foreground);
+        }
+        .checksChip.info-outline {
+          border-color: var(--info-foreground);
+          background-color: var(--info-background);
+        }
+        .checksChip.info-outline iron-icon {
+          color: var(--info-foreground);
+        }
+        .checksChip.check-circle-outline {
+          border-color: var(--success-foreground);
+          background-color: var(--success-background);
+        }
+        .checksChip.check-circle-outline iron-icon {
+          color: var(--success-foreground);
+        }
+        .checksChip.timelapse {
+        }
+        .checksChip.timelapse {
+          border-color: var(--gray-foreground);
+          background-color: var(--gray-background);
+        }
+        .checksChip.timelapse iron-icon {
+          color: var(--gray-foreground);
+        }
+      `,
+    ];
+  }
+
+  render() {
+    if (!this.text) return;
+    const chipClass = `checksChip font-small ${this.icon}`;
+    const grIcon = `gr-icons:${this.icon}`;
+    return html`
+      <div class="${chipClass}" role="button">
+        <iron-icon icon="${grIcon}"></iron-icon>
+        <div class="text">${this.text}</div>
+        <slot></slot>
+      </div>
+    `;
+  }
+}
+
+/** What is the maximum number of expanded checks chips? */
+const DETAILS_QUOTA = 3;
+
+@customElement('gr-change-summary')
+export class GrChangeSummary extends GrLitElement {
+  private readonly newChangeSummaryUiEnabled = appContext.flagsService.isEnabled(
+    KnownExperimentId.NEW_CHANGE_SUMMARY_UI
+  );
+
+  @property({type: Object})
+  changeComments?: ChangeComments;
+
+  @property({type: Array})
+  commentThreads?: CommentThread[];
+
+  @property({type: Object})
+  selfAccount?: AccountInfo;
+
+  @property()
+  runs: CheckRun[] = [];
+
+  @property()
+  showChecksSummary = false;
+
+  @property()
+  someProvidersAreLoading = false;
+
+  /** Is reset when rendering beings and decreases while chips are rendered. */
+  private detailsQuota = DETAILS_QUOTA;
+
+  constructor() {
+    super();
+    this.subscribe('runs', allRuns$);
+    this.subscribe('showChecksSummary', aPluginHasRegistered$);
+    this.subscribe('someProvidersAreLoading', someProvidersAreLoading$);
+  }
+
+  static get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+          color: var(--deemphasized-text-color);
+          /* temporary for old checks status */
+        }
+        :host.new-change-summary-true {
+          margin-bottom: var(--spacing-m);
+        }
+        .zeroState {
+          color: var(--primary-text-color);
+        }
+        td.key {
+          padding-right: var(--spacing-l);
+          padding-bottom: var(--spacing-m);
+        }
+        td.value {
+          padding-right: var(--spacing-l);
+          padding-bottom: var(--spacing-m);
+        }
+        iron-icon.launch {
+          color: var(--gray-foreground);
+          width: var(--line-height-small);
+          height: var(--line-height-small);
+          vertical-align: top;
+        }
+        gr-avatar {
+          height: var(--line-height-small, 16px);
+          width: var(--line-height-small, 16px);
+          vertical-align: top;
+          margin-right: var(--spacing-xs);
+        }
+      `,
+    ];
+  }
+
+  renderChecksZeroState() {
+    if (this.runs.some(isRunningOrHasCompleted)) return;
+    const msg = this.someProvidersAreLoading ? 'Loading...' : 'No results';
+    return html`<span class="font-small zeroState">${msg}</span>`;
+  }
+
+  renderChecksChipForCategory(category: Category) {
+    const icon = iconForCategory(category);
+    const runs = this.runs.filter(run => hasResultsOf(run, category));
+    const count = (run: CheckRun) => getResultsOf(run, category);
+    return this.renderChecksChip(icon, runs, category, count);
+  }
+
+  renderChecksChipForStatus(
+    status: RunStatus,
+    filter: (run: CheckRun) => boolean
+  ) {
+    const icon = iconForStatus(status);
+    const runs = this.runs.filter(filter);
+    return this.renderChecksChip(icon, runs, status, () => []);
+  }
+
+  renderChecksChip(
+    icon: string,
+    runs: CheckRun[],
+    statusOrCategory: RunStatus | Category,
+    resultFilter: (run: CheckRun) => CheckResult[]
+  ) {
+    if (runs.length === 0) {
+      return html``;
+    }
+    if (runs.length <= this.detailsQuota) {
+      this.detailsQuota -= runs.length;
+      return runs.map(run => {
+        const allLinks = resultFilter(run)
+          .reduce((links, result) => {
+            return links.concat(result.links ?? []);
+          }, [] as Link[])
+          .filter(link => link.primary);
+        const links = allLinks.length === 1 ? allLinks : [];
+        const text = `${run.checkName}`;
+        return html`<gr-checks-chip
+          class="${icon}"
+          .icon="${icon}"
+          .text="${text}"
+          @click="${() => this.onChipClick({checkName: run.checkName})}"
+          >${links.map(
+            link => html`
+              <a href="${link.url}" target="_blank" @click="${this.onLinkClick}"
+                ><iron-icon class="launch" icon="gr-icons:launch"></iron-icon
+              ></a>
+            `
+          )}
+        </gr-checks-chip>`;
+      });
+    }
+    // runs.length > this.detailsQuota
+    this.detailsQuota = 0;
+    const sum = runs.reduce(
+      (sum, run) => sum + (resultFilter(run).length || 1),
+      0
+    );
+    if (sum === 0) return;
+    return html`<gr-checks-chip
+      class="${icon}"
+      .icon="${icon}"
+      .text="${sum}"
+      @click="${() => this.onChipClick({statusOrCategory})}"
+    ></gr-checks-chip>`;
+  }
+
+  private onChipClick(state: ChecksTabState) {
+    fireShowPrimaryTab(this, PrimaryTab.CHECKS, true, {
+      checksTab: state,
+    });
+  }
+
+  private onLinkClick(e: MouseEvent) {
+    // Prevents onChipClick() from reacting to <a> link clicks.
+    e.stopPropagation();
+  }
+
+  render() {
+    this.detailsQuota = DETAILS_QUOTA;
+    const commentThreads =
+      this.commentThreads?.filter(t => !isRobotThread(t) || hasHumanReply(t)) ??
+      [];
+    const countResolvedComments = commentThreads.filter(isResolved).length;
+    const unresolvedThreads = commentThreads.filter(isUnresolved);
+    const countUnresolvedComments = unresolvedThreads.length;
+    const unresolvedAuthors = this.getAccounts(unresolvedThreads);
+    const draftCount = this.changeComments?.computeDraftCount() ?? 0;
+    return html`
+      <div>
+        <table>
+          <tr ?hidden=${!this.showChecksSummary}>
+            <td class="key">Checks</td>
+            <td class="value">
+              ${this.renderChecksZeroState()}${this.renderChecksChipForCategory(
+                Category.ERROR
+              )}${this.renderChecksChipForCategory(
+                Category.WARNING
+              )}${this.renderChecksChipForCategory(
+                Category.INFO
+              )}${this.renderChecksChipForStatus(
+                RunStatus.COMPLETED,
+                hasCompletedWithoutResults
+              )}${this.renderChecksChipForStatus(RunStatus.RUNNING, isRunning)}
+            </td>
+          </tr>
+          <tr ?hidden=${!this.newChangeSummaryUiEnabled}>
+            <td class="key">Comments</td>
+            <td class="value">
+              <span
+                class="font-small zeroState"
+                ?hidden=${!!countResolvedComments ||
+                !!draftCount ||
+                !!countUnresolvedComments}
+              >
+                No Comments</span
+              ><gr-summary-chip
+                styleType=${SummaryChipStyles.WARNING}
+                category=${CommentTabState.DRAFTS}
+                icon="edit"
+                ?hidden=${!draftCount}
+              >
+                ${pluralize(draftCount, 'draft')}</gr-summary-chip
+              ><gr-summary-chip
+                styleType=${SummaryChipStyles.WARNING}
+                category=${CommentTabState.UNRESOLVED}
+                ?hidden=${!countUnresolvedComments}
+              >
+                ${unresolvedAuthors.map(
+                  account =>
+                    html`<gr-avatar
+                      .account="${account}"
+                      image-size="32"
+                      aria-label="Account avatar"
+                    ></gr-avatar>`
+                )}
+                ${countUnresolvedComments} unresolved</gr-summary-chip
+              ><gr-summary-chip
+                styleType=${SummaryChipStyles.CHECK}
+                category=${CommentTabState.SHOW_ALL}
+                icon="markChatRead"
+                ?hidden=${!countResolvedComments}
+                >${countResolvedComments} resolved</gr-summary-chip
+              >
+            </td>
+          </tr>
+          <tr hidden>
+            <td class="key">Findings</td>
+            <td class="value"></td>
+          </tr>
+        </table>
+      </div>
+    `;
+  }
+
+  getAccounts(commentThreads: CommentThread[]): AccountInfo[] {
+    const uniqueAuthors = commentThreads
+      .map(getFirstComment)
+      .map(comment => comment?.author ?? this.selfAccount)
+      .filter(notUndefined)
+      .filter(account => !!account?.avatars?.[0]?.url)
+      .filter(uniqueDefinedAvatar);
+    return uniqueAuthors.slice(0, 3);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-summary': GrChangeSummary;
+    'gr-checks-chip': GrChecksChip;
+    'gr-summary-chip': GrSummaryChip;
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
similarity index 68%
copy from polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
copy to polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
index ac59f4f..cd297c7 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
@@ -14,8 +14,12 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
+import '../../../test/common-test-setup-karma';
+import {GrChangeSummary} from './gr-change-summary';
 
-export const htmlTemplate = html`
-  <gr-lib-loader id="libLoader"></gr-lib-loader>
-`;
+suite('gr-change-summary test', () => {
+  test('is defined', () => {
+    const el = document.createElement('gr-change-summary');
+    assert.instanceOf(el, GrChangeSummary);
+  });
+});
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 8504df4..ab1c48f 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
@@ -25,12 +25,11 @@
 import '../../shared/gr-change-status/gr-change-status';
 import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../shared/gr-editable-content/gr-editable-content';
-import '../../shared/gr-js-api-interface/gr-js-api-interface';
 import '../../shared/gr-linked-text/gr-linked-text';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../gr-change-actions/gr-change-actions';
+import '../gr-change-summary/gr-change-summary';
 import '../gr-change-metadata/gr-change-metadata';
 import '../../shared/gr-icons/gr-icons';
 import '../gr-commit-info/gr-commit-info';
@@ -39,10 +38,12 @@
 import '../gr-included-in-dialog/gr-included-in-dialog';
 import '../gr-messages-list/gr-messages-list';
 import '../gr-related-changes-list/gr-related-changes-list';
+import '../gr-related-changes-list-experimental/gr-related-changes-list-experimental';
 import '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
 import '../gr-reply-dialog/gr-reply-dialog';
 import '../gr-thread-list/gr-thread-list';
 import '../gr-upload-help-dialog/gr-upload-help-dialog';
+import '../../checks/gr-checks-tab';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
@@ -53,13 +54,18 @@
   Shortcut,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {GrEditConstants} from '../../edit/gr-edit-constants';
-import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter';
-import {getComputedStyleValue} from '../../../utils/dom-util';
-import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation';
+import {pluralize} from '../../../utils/string-util';
+import {
+  getComputedStyleValue,
+  windowLocationReload,
+} from '../../../utils/dom-util';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
+import {DiffViewMode} from '../../../api/diff';
 import {PrimaryTab, SecondaryTab} from '../../../constants/constants';
+
 import {NO_ROBOT_COMMENTS_THREADS_MSG} from '../../../constants/messages';
 import {appContext} from '../../../services/app-context';
 import {ChangeStatus} from '../../../constants/constants';
@@ -69,14 +75,11 @@
   fetchChangeUpdates,
   hasEditBasedOnCurrentPatchSet,
   hasEditPatchsetLoaded,
-  patchNumEquals,
   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 '../../../api/plugin';
 import {customElement, property, observe} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {GrJsApiInterface} from '../../shared/gr-js-api-interface/gr-js-api-interface-element';
 import {GrApplyFixDialog} from '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
 import {GrFileListHeader} from '../gr-file-list-header/gr-file-list-header';
 import {GrEditableContent} from '../../shared/gr-editable-content/gr-editable-content';
@@ -98,7 +101,6 @@
   ConfigInfo,
   PreferencesInfo,
   CommitInfo,
-  DiffPreferencesInfo,
   RevisionInfo,
   EditInfo,
   LabelNameToInfoMap,
@@ -106,7 +108,11 @@
   QuickLabelInfo,
   ApprovalInfo,
   ElementPropertyDeepChange,
+  ChangeId,
+  RelatedChangeAndCommitInfo,
+  RelatedChangesInfo,
 } 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';
@@ -115,7 +121,7 @@
   GrCommentApi,
   ChangeComments,
 } from '../../diff/gr-comment-api/gr-comment-api';
-import {hasOwnProperty} from '../../../utils/common-util';
+import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
 import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
 import {
   CommentThread,
@@ -133,24 +139,41 @@
 import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
 import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
 import {
-  EditRevisionInfo,
-  ParsedChangeInfo,
-} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
-import {
   GrFileList,
   DEFAULT_NUM_FILES_SHOWN,
 } from '../gr-file-list/gr-file-list';
-import {ChangeViewState, isPolymerSpliceChange} from '../../../types/types';
+import {
+  ChangeViewState,
+  EditRevisionInfo,
+  isPolymerSpliceChange,
+  ParsedChangeInfo,
+} from '../../../types/types';
 import {
   CustomKeyboardEvent,
   EditableContentSaveEvent,
   OpenFixPreviewEvent,
   ShowAlertEventDetail,
   SwitchTabEvent,
+  ThreadListModifiedEvent,
+  TabState,
 } 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 {
+  EventType,
+  fireAlert,
+  fireEvent,
+  firePageError,
+  fireDialogChange,
+} from '../../../utils/event-util';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {fireTitleChange} from '../../../utils/event-util';
+import {GerritView} from '../../../services/router/router-model';
+import {takeUntil} from 'rxjs/operators';
+import {aPluginHasRegistered$} from '../../../services/checks/checks-model';
+import {Subject} from 'rxjs';
+import {GrRelatedChangesListExperimental} from '../gr-related-changes-list-experimental/gr-related-changes-list-experimental';
 
 const CHANGE_ID_ERROR = {
   MISMATCH: 'mismatch',
@@ -188,11 +211,6 @@
   NEW_MESSAGE: 'There are new messages on this change',
 };
 
-enum DiffViewMode {
-  SIDE_BY_SIDE = 'SIDE_BY_SIDE',
-  UNIFIED = 'UNIFIED_DIFF',
-}
-
 const CHANGE_DATA_TIMING_LABEL = 'ChangeDataLoaded';
 const CHANGE_RELOAD_TIMING_LABEL = 'ChangeReloaded';
 const SEND_REPLY_TIMING_LABEL = 'SendReply';
@@ -201,8 +219,6 @@
 
 export interface GrChangeView {
   $: {
-    restAPI: RestApiService & Element;
-    jsAPI: GrJsApiInterface;
     commentAPI: GrCommentApi;
     applyFixDialog: GrApplyFixDialog;
     fileList: GrFileList & Element;
@@ -216,23 +232,22 @@
     replyOverlay: GrOverlay;
     replyDialog: GrReplyDialog;
     mainContent: HTMLDivElement;
-    relatedChanges: GrRelatedChangesList;
     changeStar: GrChangeStar;
     actions: GrChangeActions;
     commitMessage: HTMLDivElement;
     commitAndRelated: HTMLDivElement;
     metadata: GrChangeMetadata;
-    relatedChangesToggle: HTMLDivElement;
     mainChangeInfo: HTMLDivElement;
-    commitCollapseToggleButton: GrButton;
-    commitCollapseToggle: HTMLDivElement;
-    relatedChangesToggleButton: GrButton;
     replyBtn: GrButton;
   };
 }
 
 export type ChangeViewPatchRange = Partial<PatchRange>;
 
+const DEBOUNCER_REPLY_OVERLAY_REFIT = 'reply-overlay-refit';
+
+const DEBOUNCER_SCROLL = 'scroll';
+
 @customElement('gr-change-view')
 export class GrChangeView extends KeyboardShortcutMixin(
   GestureEventListeners(LegacyElementMixin(PolymerElement))
@@ -259,7 +274,13 @@
    * @event show-auth-required
    */
 
-  reporting = appContext.reportingService;
+  private readonly reporting = appContext.reportingService;
+
+  private readonly flagsService = appContext.flagsService;
+
+  private readonly jsAPI = appContext.jsApiService;
+
+  private readonly changeService = appContext.changeService;
 
   /**
    * URL params passed from the router.
@@ -515,6 +536,11 @@
   @property({type: Number})
   _currentRobotCommentsPatchSet?: PatchSetNum;
 
+  // TODO(milutin) - remove once new gr-dialog will do it out of the box
+  // This removes rest of page from a11y tree, when reply dialog is open
+  @property({type: Boolean})
+  _changeViewAriaHidden = false;
+
   /**
    * this is a two-element tuple to always
    * hold the current active tab for both primary and secondary tabs
@@ -530,6 +556,19 @@
 
   _throttledToggleChangeStar?: EventListener;
 
+  @property({type: Boolean})
+  _showChecksTab = false;
+
+  @property({type: Boolean})
+  _isNewChangeSummaryUiEnabled = false;
+
+  @property({type: String})
+  _tabState?: TabState;
+
+  restApiService = appContext.restApiService;
+
+  checksService = appContext.checksService;
+
   keyboardShortcuts() {
     return {
       [Shortcut.SEND_REPLY]: null, // DOC_ONLY binding
@@ -542,7 +581,6 @@
       [Shortcut.UP_TO_DASHBOARD]: '_handleUpToDashboard',
       [Shortcut.EXPAND_ALL_MESSAGES]: '_handleExpandAllMessages',
       [Shortcut.COLLAPSE_ALL_MESSAGES]: '_handleCollapseAllMessages',
-      [Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_expandAllDiffs',
       [Shortcut.OPEN_DIFF_PREFS]: '_handleOpenDiffPrefsShortcut',
       [Shortcut.EDIT_TOPIC]: '_handleEditTopic',
       [Shortcut.DIFF_AGAINST_BASE]: '_handleDiffAgainstBase',
@@ -553,6 +591,19 @@
     };
   }
 
+  disconnected$ = new Subject();
+
+  /** @override */
+  ready() {
+    super.ready();
+    aPluginHasRegistered$.pipe(takeUntil(this.disconnected$)).subscribe(b => {
+      this._showChecksTab = b;
+    });
+    this._isNewChangeSummaryUiEnabled = this.flagsService.isEnabled(
+      KnownExperimentId.NEW_CHANGE_SUMMARY_UI
+    );
+  }
+
   /** @override */
   connectedCallback() {
     super.connectedCallback();
@@ -562,6 +613,12 @@
   }
 
   /** @override */
+  disconnectedCallback() {
+    this.disconnected$.next();
+    super.disconnectedCallback();
+  }
+
+  /** @override */
   created() {
     super.created();
 
@@ -585,6 +642,11 @@
       this._handleReloadCommentThreads()
     );
 
+    this.addEventListener(
+      'thread-list-modified',
+      (e: ThreadListModifiedEvent) => this._handleReloadDiffComments(e)
+    );
+
     this.addEventListener('open-reply-dialog', () => this._openReplyDialog());
   }
 
@@ -599,7 +661,7 @@
     this._getLoggedIn().then(loggedIn => {
       this._loggedIn = loggedIn;
       if (loggedIn) {
-        this.$.restAPI.getAccount().then(acct => {
+        this.restApiService.getAccount().then(acct => {
           this._account = acct;
         });
       }
@@ -641,7 +703,7 @@
     this.listen(window, 'scroll', '_handleScroll');
     this.listen(document, 'visibilitychange', '_handleVisibilityChange');
 
-    this.addEventListener('show-primary-tab', e =>
+    this.addEventListener(EventType.SHOW_PRIMARY_TAB, e =>
       this._setActivePrimaryTab(e)
     );
     this.addEventListener('show-secondary-tab', e =>
@@ -661,6 +723,8 @@
     super.detached();
     this.unlisten(window, 'scroll', '_handleScroll');
     this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
+    this.cancelDebouncer(DEBOUNCER_REPLY_OVERLAY_REFIT);
+    this.cancelDebouncer(DEBOUNCER_SCROLL);
 
     if (this._updateCheckTimerHandle) {
       this._cancelUpdateCheckTimer();
@@ -668,11 +732,11 @@
   }
 
   get messagesList(): GrMessagesList | null {
-    return this.shadowRoot!.querySelector('gr-messages-list');
+    return this.shadowRoot!.querySelector<GrMessagesList>('gr-messages-list');
   }
 
   get threadList(): GrThreadList | null {
-    return this.shadowRoot!.querySelector('gr-thread-list');
+    return this.shadowRoot!.querySelector<GrThreadList>('gr-thread-list');
   }
 
   _changeStatusString(change: ChangeInfo) {
@@ -728,13 +792,14 @@
    * @param paperTabs - the parent tabs container
    */
   _setActiveTab(
-    paperTabs: PaperTabsElement,
+    paperTabs: PaperTabsElement | null,
     activeDetails: {
       activeTabName?: string;
       activeTabIndex?: number;
       scrollIntoView?: boolean;
     }
   ) {
+    if (!paperTabs) return;
     const {activeTabName, activeTabIndex, scrollIntoView} = activeDetails;
     const tabs = paperTabs.querySelectorAll('paper-tab') as NodeListOf<
       HTMLElement
@@ -760,8 +825,11 @@
       paperTabs.scrollIntoView();
     }
     if (paperTabs.selected !== activeIndex) {
+      // paperTabs.selected is undefined during rendering
+      if (paperTabs.selected !== undefined) {
+        this.reporting.reportInteraction('show-tab', {tabName});
+      }
       paperTabs.selected = activeIndex;
-      this.reporting.reportInteraction('show-tab', {tabName});
     }
     return tabName;
   }
@@ -770,9 +838,9 @@
    * Changes active primary tab.
    */
   _setActivePrimaryTab(e: SwitchTabEvent) {
-    const primaryTabs = this.shadowRoot!.querySelector(
+    const primaryTabs = this.shadowRoot!.querySelector<PaperTabsElement>(
       '#primaryTabs'
-    ) as PaperTabsElement;
+    );
     const activeTabName = this._setActiveTab(primaryTabs, {
       activeTabName: e.detail.tab,
       activeTabIndex: e.detail.value,
@@ -797,15 +865,16 @@
         this._selectedTabPluginHeader = '';
       }
     }
+    this._tabState = e.detail.tabState;
   }
 
   /**
    * Changes active secondary tab.
    */
   _setActiveSecondaryTab(e: SwitchTabEvent) {
-    const secondaryTabs = this.shadowRoot!.querySelector(
+    const secondaryTabs = this.shadowRoot!.querySelector<PaperTabsElement>(
       '#secondaryTabs'
-    ) as PaperTabsElement;
+    );
     const activeTabName = this._setActiveTab(secondaryTabs, {
       activeTabName: e.detail.tab,
       activeTabIndex: e.detail.value,
@@ -822,16 +891,16 @@
   }
 
   _handleCommitMessageSave(e: EditableContentSaveEvent) {
-    if (!this._change) throw new Error('missing required change property');
+    assertIsDefined(this._change, '_change');
     if (!this._changeNum)
       throw new Error('missing required changeNum property');
     // Trim trailing whitespace from each line.
     const message = e.detail.content.replace(TRAILING_WHITESPACE_REGEX, '');
 
-    this.$.jsAPI.handleCommitMessage(this._change, message);
+    this.jsAPI.handleCommitMessage(this._change, message);
 
     this.$.commitMessageEditor.disabled = true;
-    this.$.restAPI
+    this.restApiService
       .putChangeCommitMessage(this._changeNum, message)
       .then(resp => {
         this.$.commitMessageEditor.disabled = false;
@@ -849,7 +918,7 @@
   }
 
   _reloadWindow() {
-    window.location.reload();
+    windowLocationReload();
   }
 
   _handleCommitMessageCancel() {
@@ -886,12 +955,15 @@
     collapsed?: boolean,
     collapsible?: boolean
   ) {
+    const hideWhenCollapsed = this._isNewChangeSummaryUiEnabled
+      ? false
+      : collapsed && collapsible;
     if (
       !loggedIn ||
       editing ||
       (change && change.status === ChangeStatus.MERGED) ||
       editMode ||
-      (collapsed && collapsible)
+      hideWhenCollapsed
     ) {
       return true;
     }
@@ -918,8 +990,7 @@
     const commentCount = this._robotCommentCountPerPatchSet(commentThreads);
     const commentCnt = commentCount[patch._number] || 0;
     if (commentCnt === 0) return `Patchset ${patch._number}`;
-    const findingsText = commentCnt === 1 ? 'finding' : 'findings';
-    return `Patchset ${patch._number} (${commentCnt} ${findingsText})`;
+    return `Patchset ${patch._number} (${pluralize(commentCnt, 'finding')})`;
   }
 
   _computeRobotCommentsPatchSetDropdownItems(
@@ -1008,14 +1079,9 @@
   ) {
     if (!changeComments) return undefined;
     const draftCount = changeComments.computeDraftCount();
-    const unresolvedString = GrCountStringFormatter.computeString(
-      unresolvedCount,
-      'unresolved'
-    );
-    const draftString = GrCountStringFormatter.computePluralString(
-      draftCount,
-      'draft'
-    );
+    const unresolvedString =
+      unresolvedCount === 0 ? '' : `${unresolvedCount} unresolved`;
+    const draftString = pluralize(draftCount, 'draft');
 
     return (
       unresolvedString +
@@ -1103,6 +1169,11 @@
     this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
   }
 
+  onReplyOverlayCanceled() {
+    fireDialogChange(this, {canceled: true});
+    this._changeViewAriaHidden = false;
+  }
+
   _handleOpenDiffPrefs() {
     this.$.fileList.openDiffPrefs();
   }
@@ -1167,18 +1238,18 @@
       },
       {once: true}
     );
-    this.$.replyOverlay.close();
+    this.$.replyOverlay.cancel();
     this._reload();
   }
 
   _handleReplyCancel() {
-    this.$.replyOverlay.close();
+    this.$.replyOverlay.cancel();
   }
 
   _handleReplyAutogrow() {
     // If the textarea resizes, we need to re-fit the overlay.
     this.debounce(
-      'reply-overlay-refit',
+      DEBOUNCER_REPLY_OVERLAY_REFIT,
       () => {
         this.$.replyOverlay.refit();
       },
@@ -1196,7 +1267,7 @@
 
   _handleScroll() {
     this.debounce(
-      'scroll',
+      DEBOUNCER_SCROLL,
       () => {
         this.viewState.scrollTop = document.body.scrollTop;
       },
@@ -1226,7 +1297,7 @@
     }
 
     if (value.changeNum && value.project) {
-      this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
+      this.restApiService.setInProjectLookup(value.changeNum, value.project);
     }
 
     const patchChanged =
@@ -1237,6 +1308,11 @@
         this._patchRange.basePatchNum !== value.basePatchNum);
     const changeChanged = this._changeNum !== value.changeNum;
 
+    let rightPatchNumChanged =
+      this._patchRange &&
+      value.patchNum !== undefined &&
+      this._patchRange.patchNum !== value.patchNum;
+
     const patchRange: ChangeViewPatchRange = {
       patchNum: value.patchNum,
       basePatchNum: value.basePatchNum || ParentPatchSetNum,
@@ -1250,8 +1326,9 @@
     if (!changeChanged && patchChanged) {
       if (!patchRange.patchNum) {
         patchRange.patchNum = computeLatestPatchNum(this._allPatchSets);
+        rightPatchNumChanged = true;
       }
-      this._reloadPatchNumDependentResources().then(() => {
+      this._reloadPatchNumDependentResources(rightPatchNumChanged).then(() => {
         this._sendShowChangeEvent();
       });
       return;
@@ -1259,8 +1336,7 @@
 
     this._initialLoadComplete = false;
     this._changeNum = value.changeNum;
-    this.$.relatedChanges.clear();
-
+    this.getRelatedChangesList()?.clear();
     this._reload(true).then(() => {
       this._performPostLoadTasks();
     });
@@ -1296,7 +1372,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},
@@ -1356,7 +1432,7 @@
   }
 
   _handleMessageAnchorTap(e: CustomEvent<{id: string}>) {
-    if (!this._change) throw new Error('missing required change property');
+    assertIsDefined(this._change, '_change');
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
     const hash = MSG_PREFIX + e.detail.id;
@@ -1473,13 +1549,7 @@
     this.set('_patchRange.basePatchNum', parent);
 
     const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, title);
   }
 
   /**
@@ -1613,12 +1683,7 @@
     }
     this._getLoggedIn().then(isLoggedIn => {
       if (!isLoggedIn) {
-        this.dispatchEvent(
-          new CustomEvent('show-auth-required', {
-            composed: true,
-            bubbles: true,
-          })
-        );
+        fireEvent(this, 'show-auth-required');
         return;
       }
 
@@ -1649,19 +1714,11 @@
     if (this.shouldSuppressKeyboardShortcut(e)) {
       return;
     }
-    if (!this._change) throw new Error('missing required change property');
+    assertIsDefined(this._change, '_change');
     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,
-        })
-      );
+    if (this._patchRange.basePatchNum === ParentPatchSetNum) {
+      fireAlert(this, 'Base is already selected.');
       return;
     }
     GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
@@ -1671,19 +1728,11 @@
     if (this.shouldSuppressKeyboardShortcut(e)) {
       return;
     }
-    if (!this._change) throw new Error('missing required change property');
+    assertIsDefined(this._change, '_change');
     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,
-        })
-      );
+    if (this._patchRange.basePatchNum === ParentPatchSetNum) {
+      fireAlert(this, 'Left is already base.');
       return;
     }
     GerritNav.navigateToChange(this._change, this._patchRange.basePatchNum);
@@ -1693,21 +1742,12 @@
     if (this.shouldSuppressKeyboardShortcut(e)) {
       return;
     }
-    if (!this._change) throw new Error('missing required change property');
+    assertIsDefined(this._change, '_change');
     if (!this._patchRange)
       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,
-          composed: true,
-          bubbles: true,
-        })
-      );
+    if (this._patchRange.patchNum === latestPatchNum) {
+      fireAlert(this, 'Latest is already selected.');
       return;
     }
     GerritNav.navigateToChange(
@@ -1721,20 +1761,12 @@
     if (this.shouldSuppressKeyboardShortcut(e)) {
       return;
     }
-    if (!this._change) throw new Error('missing required change property');
+    assertIsDefined(this._change, '_change');
     const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
     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,
-        })
-      );
+    if (this._patchRange.patchNum === latestPatchNum) {
+      fireAlert(this, 'Right is already latest.');
       return;
     }
     GerritNav.navigateToChange(
@@ -1748,23 +1780,15 @@
     if (this.shouldSuppressKeyboardShortcut(e)) {
       return;
     }
-    if (!this._change) throw new Error('missing required change property');
+    assertIsDefined(this._change, '_change');
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
     const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
     if (
-      patchNumEquals(this._patchRange.patchNum, latestPatchNum) &&
-      patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)
+      this._patchRange.patchNum === latestPatchNum &&
+      this._patchRange.basePatchNum === ParentPatchSetNum
     ) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'Already diffing base against latest.',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, 'Already diffing base against latest.');
       return;
     }
     GerritNav.navigateToChange(this._change, latestPatchNum);
@@ -1872,7 +1896,7 @@
         changeRecord.path
       );
     }
-    this.$.jsAPI.handleEvent(EventType.LABEL_CHANGE, {
+    this.jsAPI.handleEvent(PluginEventType.LABEL_CHANGE, {
       change: this._change,
     });
   }
@@ -1885,29 +1909,25 @@
       flush();
       this.$.replyOverlay.center();
     });
+    fireDialogChange(this, {opened: true});
+    this._changeViewAriaHidden = true;
   }
 
   _handleGetChangeDetailError(response?: Response | null) {
-    this.dispatchEvent(
-      new CustomEvent('page-error', {
-        detail: {response},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    firePageError(response);
   }
 
   _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
+    return this.restApiService.getLoggedIn();
   }
 
   _getServerConfig() {
-    return this.$.restAPI.getConfig();
+    return this.restApiService.getConfig();
   }
 
   _getProjectConfig() {
-    if (!this._change) throw new Error('missing required change property');
-    return this.$.restAPI
+    assertIsDefined(this._change, '_change');
+    return this.restApiService
       .getProjectConfig(this._change.project)
       .then(config => {
         this._projectConfig = config;
@@ -1915,7 +1935,7 @@
   }
 
   _getPreferences() {
-    return this.$.restAPI.getPreferences();
+    return this.restApiService.getPreferences();
   }
 
   _prepareCommitMsgForLinkify(msg: string) {
@@ -1965,8 +1985,9 @@
   _getChangeDetail() {
     if (!this._changeNum)
       throw new Error('missing required changeNum property');
-    const detailCompletes = this.$.restAPI.getChangeDetail(this._changeNum, r =>
-      this._handleGetChangeDetailError(r)
+    const detailCompletes = this.restApiService.getChangeDetail(
+      this._changeNum,
+      r => this._handleGetChangeDetailError(r)
     );
     const editCompletes = this._getEdit();
     const prefCompletes = this._getPreferences();
@@ -2006,10 +2027,11 @@
         this._lineHeight = Number(lineHeight.slice(0, lineHeight.length - 2));
 
         this._change = change;
+        this.changeService.updateChange(change);
         if (
           !this._patchRange ||
           !this._patchRange.patchNum ||
-          patchNumEquals(this._patchRange.patchNum, currentRevision._number)
+          this._patchRange.patchNum === currentRevision._number
         ) {
           // CommitInfo.commit is optional, and may need patching.
           if (currentRevision.commit && !currentRevision.commit.commit) {
@@ -2055,7 +2077,7 @@
   _getEdit() {
     if (!this._changeNum)
       return Promise.reject(new Error('missing required changeNum property'));
-    return this.$.restAPI.getChangeEdit(this._changeNum, true);
+    return this.restApiService.getChangeEdit(this._changeNum, true);
   }
 
   _getLatestCommitMessage() {
@@ -2064,7 +2086,7 @@
     const lastpatchNum = computeLatestPatchNum(this._allPatchSets);
     if (lastpatchNum === undefined)
       throw new Error('missing lastPatchNum property');
-    return this.$.restAPI
+    return this.restApiService
       .getChangeCommitInfo(this._changeNum, lastpatchNum)
       .then(commitInfo => {
         if (!commitInfo) return;
@@ -2075,21 +2097,15 @@
   }
 
   _getLatestRevisionSHA(change: ChangeInfo | ParsedChangeInfo) {
-    if (change.current_revision) {
-      return change.current_revision;
-    }
+    if (change.current_revision) return change.current_revision;
     // current_revision may not be present in the case where the latest rev is
     // a draft and the user doesn’t have permission to view that rev.
     let latestRev = null;
     let latestPatchNum = -1 as PatchSetNum;
-    for (const rev in change.revisions) {
-      if (!hasOwnProperty(change.revisions, rev)) {
-        continue;
-      }
-
-      if (change.revisions[rev]._number > latestPatchNum) {
+    for (const [rev, revInfo] of Object.entries(change.revisions ?? {})) {
+      if (revInfo._number > latestPatchNum) {
         latestRev = rev;
-        latestPatchNum = change.revisions[rev]._number;
+        latestPatchNum = revInfo._number;
       }
     }
     return latestRev;
@@ -2102,7 +2118,7 @@
       throw new Error('missing required _patchRange property');
     if (this._patchRange.patchNum === undefined)
       throw new Error('missing required patchNum property');
-    return this.$.restAPI
+    return this.restApiService
       .getChangeCommitInfo(this._changeNum, this._patchRange.patchNum)
       .then(commitInfo => {
         this._commitInfo = commitInfo;
@@ -2127,9 +2143,12 @@
     this._robotCommentThreads = undefined;
     if (!this._changeNum)
       throw new Error('missing required changeNum property');
+
     return this.$.commentAPI
-      .loadAll(this._changeNum)
-      .then(comments => this._recomputeComments(comments));
+      .loadAll(this._changeNum, this._patchRange?.patchNum)
+      .then(comments => {
+        this._recomputeComments(comments);
+      });
   }
 
   /**
@@ -2195,18 +2214,14 @@
     // are loaded.
     const detailCompletes = this._getChangeDetail();
     allDataPromises.push(detailCompletes);
+    this.checksService.reloadAll();
 
     // Resolves when the loading flag is set to false, meaning that some
     // change content may start appearing.
     const loadingFlagSet = detailCompletes
       .then(() => {
         this._loading = false;
-        this.dispatchEvent(
-          new CustomEvent('change-details-loaded', {
-            bubbles: true,
-            composed: true,
-          })
-        );
+        fireEvent(this, 'change-details-loaded');
       })
       .then(() => {
         this.reporting.timeEnd(CHANGE_RELOAD_TIMING_LABEL);
@@ -2284,9 +2299,32 @@
 
     if (isLocationChange) {
       this._editingCommitMessage = false;
-      const relatedChangesLoaded = coreDataPromise.then(() =>
-        this.$.relatedChanges.reload()
-      );
+      const relatedChangesLoaded = coreDataPromise.then(() => {
+        this.getRelatedChangesList()?.reload();
+        if (this._isNewChangeSummaryUiEnabled) {
+          let relatedChangesPromise:
+            | Promise<RelatedChangesInfo | undefined>
+            | undefined;
+          const patchNum = this._computeLatestPatchNum(this._allPatchSets);
+          if (this._change && patchNum) {
+            relatedChangesPromise = this.restApiService
+              .getRelatedChanges(this._change._number, patchNum)
+              .then(response => {
+                if (this._change && response) {
+                  this.hasParent = this._calculateHasParent(
+                    this._change.change_id,
+                    response.changes
+                  );
+                }
+                return response;
+              });
+          }
+          // TODO: use returned Promise
+          this.getRelatedChangesListExperimental()?.reload(
+            relatedChangesPromise
+          );
+        }
+      });
       allDataPromises.push(relatedChangesLoaded);
     }
 
@@ -2301,11 +2339,36 @@
   }
 
   /**
+   * Determines whether or not the given change has a parent change. If there
+   * is a relation chain, and the change id is not the last item of the
+   * relation chain, there is a parent.
+   */
+  _calculateHasParent(
+    currentChangeId: ChangeId,
+    relatedChanges: RelatedChangeAndCommitInfo[]
+  ) {
+    return (
+      relatedChanges.length > 0 &&
+      relatedChanges[relatedChanges.length - 1].change_id !== currentChangeId
+    );
+  }
+
+  /**
    * Kicks off requests for resources that rely on the patch range
    * (`this._patchRange`) being defined.
    */
-  _reloadPatchNumDependentResources() {
-    return Promise.all([this._getCommitInfo(), this.$.fileList.reload()]);
+  _reloadPatchNumDependentResources(rightPatchNumChanged?: boolean) {
+    assertIsDefined(this._changeNum, '_changeNum');
+    if (!this._patchRange?.patchNum) throw new Error('missing patchNum');
+    const promises = [this._getCommitInfo(), this.$.fileList.reload()];
+    if (rightPatchNumChanged)
+      promises.push(
+        this.$.commentAPI.reloadPortedComments(
+          this._changeNum,
+          this._patchRange?.patchNum
+        )
+      );
+    return Promise.all(promises);
   }
 
   _getMergeability() {
@@ -2329,11 +2392,13 @@
     }
 
     this._mergeable = null;
-    return this.$.restAPI.getMergeable(this._changeNum).then(mergableInfo => {
-      if (mergableInfo) {
-        this._mergeable = mergableInfo.mergeable;
-      }
-    });
+    return this.restApiService
+      .getMergeable(this._changeNum)
+      .then(mergableInfo => {
+        if (mergableInfo) {
+          this._mergeable = mergableInfo.mergeable;
+        }
+      });
   }
 
   _computeCanStartReview(change: ChangeInfo) {
@@ -2353,6 +2418,9 @@
   }
 
   _computeCommitMessageCollapsed(collapsed?: boolean, collapsible?: boolean) {
+    if (this._isNewChangeSummaryUiEnabled) {
+      return false;
+    }
     return collapsible && collapsed;
   }
 
@@ -2395,7 +2463,10 @@
     if (!commitMessage) {
       return false;
     }
-    return commitMessage.split('\n').length >= MIN_LINES_FOR_COMMIT_COLLAPSE;
+    const MIN_LINES = this._isNewChangeSummaryUiEnabled
+      ? 15
+      : MIN_LINES_FOR_COMMIT_COLLAPSE;
+    return commitMessage.split('\n').length >= MIN_LINES;
   }
 
   _getOffsetHeight(element: HTMLElement) {
@@ -2455,9 +2526,13 @@
     }
     const stylesToUpdate: {[key: string]: string} = {};
 
+    const relatedChanges = this.getRelatedChangesList();
     // Get the line height of related changes, and convert it to the nearest
     // integer.
-    const lineHeight = this._getLineHeight(this.$.relatedChanges);
+    const DEFAULT_LINE_HEIGHT = 20;
+    const lineHeight = relatedChanges
+      ? this._getLineHeight(relatedChanges)
+      : DEFAULT_LINE_HEIGHT;
 
     // Figure out a new height that is divisible by the rounded line height.
     const remainder = newHeight % lineHeight;
@@ -2481,25 +2556,34 @@
     }
     // Prevents showMore from showing when click on related change, since the
     // line height would be positive, but related changes height is 0.
-    if (!this._getScrollHeight(this.$.relatedChanges)) {
-      return (this._showRelatedToggle = false);
-    }
+    const relatedChanges = this.getRelatedChangesList();
+    if (relatedChanges) {
+      if (!this._getScrollHeight(relatedChanges)) {
+        return (this._showRelatedToggle = false);
+      }
 
-    if (
-      this._getScrollHeight(this.$.relatedChanges) >
-      this._getOffsetHeight(this.$.relatedChanges) +
-        this._getLineHeight(this.$.relatedChanges)
-    ) {
-      return (this._showRelatedToggle = true);
+      if (
+        this._getScrollHeight(relatedChanges) >
+        this._getOffsetHeight(relatedChanges) +
+          this._getLineHeight(relatedChanges)
+      ) {
+        return (this._showRelatedToggle = true);
+      }
     }
     return (this._showRelatedToggle = false);
   }
 
   _updateToggleContainerClass(showRelatedToggle: boolean) {
+    const relatedChangesToggle = this.shadowRoot!.querySelector<HTMLDivElement>(
+      '#relatedChangesToggle'
+    );
+    if (!relatedChangesToggle) {
+      return;
+    }
     if (showRelatedToggle) {
-      this.$.relatedChangesToggle.classList.add('showToggle');
+      relatedChangesToggle.classList.add('showToggle');
     } else {
-      this.$.relatedChangesToggle.classList.remove('showToggle');
+      relatedChangesToggle.classList.remove('showToggle');
     }
   }
 
@@ -2514,9 +2598,9 @@
     }
 
     this._updateCheckTimerHandle = this.async(() => {
-      if (!this._change) throw new Error('missing required change property');
+      assertIsDefined(this._change, '_change');
       const change = this._change;
-      fetchChangeUpdates(change, this.$.restAPI).then(result => {
+      fetchChangeUpdates(change, this.restApiService).then(result => {
         let toastMessage = null;
         if (!result.isLatest) {
           toastMessage = ReloadToastMessage.NEWER_REVISION;
@@ -2528,6 +2612,9 @@
           toastMessage = ReloadToastMessage.RESTORED;
         } else if (result.newMessages) {
           toastMessage = ReloadToastMessage.NEW_MESSAGE;
+          if (result.newMessages.author?.name) {
+            toastMessage += ` from ${result.newMessages.author.name}`;
+          }
         }
 
         // We have to make sure that the update is still relevant for the user.
@@ -2542,11 +2629,12 @@
 
         this._cancelUpdateCheckTimer();
         this.dispatchEvent(
-          new CustomEvent('show-alert', {
+          new CustomEvent<ShowAlertEventDetail>('show-alert', {
             detail: {
               message: toastMessage,
               // Persist this alert.
               dismissOnNavigation: true,
+              showDismiss: true,
               action: 'Reload',
               callback: () => {
                 this._reload(
@@ -2579,7 +2667,7 @@
   }
 
   _handleTopicChanged() {
-    this.$.relatedChanges.reload();
+    this.getRelatedChangesList()?.reload();
   }
 
   _computeHeaderClass(editMode?: boolean) {
@@ -2609,16 +2697,16 @@
     }
 
     const patchRange = patchRangeRecord.base || {};
-    return patchNumEquals(patchRange.patchNum, EditPatchSetNum);
+    return patchRange.patchNum === EditPatchSetNum;
   }
 
   _handleFileActionTap(e: CustomEvent<{path: string; action: string}>) {
     e.preventDefault();
-    const controls = this.$.fileListHeader.shadowRoot!.querySelector(
-      '#editControls'
-    ) as GrEditControls | null;
+    const controls = this.$.fileListHeader.shadowRoot!.querySelector<
+      GrEditControls
+    >('#editControls');
     if (!controls) throw new Error('Missing edit controls');
-    if (!this._change) throw new Error('missing required change property');
+    assertIsDefined(this._change, '_change');
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
     const path = e.detail.path;
@@ -2653,7 +2741,7 @@
     if (!this._selectedRevision) {
       return;
     }
-    if (!this._change) throw new Error('missing required change property');
+    assertIsDefined(this._change, '_change');
 
     let patchNum: PatchSetNum;
     if (patchNumStr === 'edit') {
@@ -2693,10 +2781,7 @@
       throw new Error('missing required _patchRange property');
     let patchNum;
     if (
-      !patchNumEquals(
-        this._patchRange.patchNum,
-        computeLatestPatchNum(this._allPatchSets)
-      )
+      !(this._patchRange.patchNum === computeLatestPatchNum(this._allPatchSets))
     ) {
       patchNum = this._patchRange.patchNum;
     }
@@ -2704,7 +2789,7 @@
   }
 
   _handleStopEditTap() {
-    if (!this._change) throw new Error('missing required change property');
+    assertIsDefined(this._change, '_change');
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
     GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
@@ -2715,7 +2800,10 @@
   }
 
   _handleToggleStar(e: CustomEvent<{change: ChangeInfo; starred: boolean}>) {
-    this.$.restAPI.saveChangeStarred(e.detail.change._number, e.detail.starred);
+    this.restApiService.saveChangeStarred(
+      e.detail.change._number,
+      e.detail.starred
+    );
   }
 
   _getRevisionInfo(change: ChangeInfo | ParsedChangeInfo) {
@@ -2736,7 +2824,7 @@
   /**
    * Wrapper for using in the element template and computed properties
    */
-  _computeLatestPatchNum(allPatchSets: PatchSet[]) {
+  _computeLatestPatchNum(allPatchSets?: PatchSet[]) {
     return computeLatestPatchNum(allPatchSets);
   }
 
@@ -2769,6 +2857,18 @@
   _computeAllPatchSets(change: ChangeInfo) {
     return computeAllPatchSets(change);
   }
+
+  getRelatedChangesList() {
+    return this.shadowRoot!.querySelector<GrRelatedChangesList>(
+      '#relatedChanges'
+    );
+  }
+
+  getRelatedChangesListExperimental() {
+    return this.shadowRoot!.querySelector<GrRelatedChangesListExperimental>(
+      '#relatedChangesExperimental'
+    );
+  }
 }
 
 declare global {
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..08c04e8 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
@@ -65,7 +65,7 @@
       margin-left: var(--spacing-s);
     }
     #replyBtn {
-      margin-bottom: var(--spacing-l);
+      margin-bottom: var(--spacing-m);
     }
     gr-change-star {
       margin-left: var(--spacing-s);
@@ -105,7 +105,7 @@
       font-size: var(--font-size-mono);
       line-height: var(--line-height-mono);
       margin-right: var(--spacing-l);
-      margin-bottom: var(--spacing-l);
+      margin-bottom: var(--spacing-m);
       /* Account for border and padding and rounding errors. */
       max-width: calc(72ch + 2px + 2 * var(--spacing-m) + 0.4px);
     }
@@ -115,6 +115,10 @@
     #commitMessageEditor {
       /* Account for border and padding and rounding errors. */
       min-width: calc(72ch + 2px + 2 * var(--spacing-m) + 0.4px);
+      --collapsed-max-height: 36em;
+    }
+    .new-change-summary-true #commitMessageEditor {
+      --collapsed-max-height: 300px;
     }
     .editCommitMessage {
       margin-top: var(--spacing-l);
@@ -232,6 +236,10 @@
       padding-top: var(--spacing-l);
       width: 100%;
     }
+    gr-change-summary.new-change-summary-true {
+      /* temporary for old checks status */
+      margin-bottom: var(--spacing-m);
+    }
     /* NOTE: If you update this breakpoint, also update the
       BREAKPOINT_RELATED_MED in the JS */
     @media screen and (max-width: 75em) {
@@ -346,6 +354,7 @@
     class="container"
     on-show-checks-table="_setActivePrimaryTab"
     hidden$="{{_loading}}"
+    aria-hidden="[[_changeViewAriaHidden]]"
   >
     <section class="changeInfoSection">
       <div class$="[[_computeHeaderClass(_editMode)]]">
@@ -405,6 +414,7 @@
             has-parent="[[hasParent]]"
             actions="[[_change.actions]]"
             revision-actions="{{_currentRevisionActions}}"
+            account="[[_account]]"
             change-num="[[_changeNum]]"
             change-status="[[_change.status]]"
             commit-num="[[_commitInfo.commit]]"
@@ -417,6 +427,7 @@
             on-edit-tap="_handleEditTap"
             on-stop-edit-tap="_handleStopEditTap"
             on-download-tap="_handleOpenDownloadDialog"
+            comment-threads="[[_commentThreads]]"
           ></gr-change-actions>
         </div>
         <!-- end commit actions -->
@@ -454,12 +465,17 @@
                   >[[_replyButtonLabel]]</gr-button
                 >
               </div>
-              <div id="commitMessage" class="commitMessage">
+              <div
+                id="commitMessage"
+                class$="commitMessage new-change-summary-[[_isNewChangeSummaryUiEnabled]]"
+              >
                 <gr-editable-content
                   id="commitMessageEditor"
-                  editing="[[_editingCommitMessage]]"
+                  editing="{{_editingCommitMessage}}"
                   content="{{_latestCommitMessage}}"
                   storage-key="[[_computeCommitMessageKey(_change._number, _change.current_revision)]]"
+                  hide-edit-commit-message="[[_hideEditCommitMessage]]"
+                  commit-collapsible="[[_commitCollapsible]]"
                   remove-zero-width-space=""
                   collapsed$="[[_computeCommitMessageCollapsed(_commitCollapsed, _commitCollapsible)]]"
                 >
@@ -470,42 +486,53 @@
                     remove-zero-width-space=""
                   ></gr-linked-text>
                 </gr-editable-content>
-                <gr-button
-                  link=""
-                  class="editCommitMessage"
-                  title="Edit commit message"
-                  on-click="_handleEditCommitMessage"
-                  hidden$="[[_hideEditCommitMessage]]"
-                  >Edit</gr-button
-                >
-                <div
-                  class="changeId"
-                  hidden$="[[!_changeIdCommitMessageError]]"
-                >
-                  <hr />
-                  Change-Id:
-                  <span
-                    class$="[[_computeChangeIdClass(_changeIdCommitMessageError)]]"
-                    title$="[[_computeTitleAttributeWarning(_changeIdCommitMessageError)]]"
+                <template is="dom-if" if="[[!_isNewChangeSummaryUiEnabled]]">
+                  <gr-button
+                    link=""
+                    class="editCommitMessage"
+                    title="Edit commit message"
+                    on-click="_handleEditCommitMessage"
+                    hidden$="[[_hideEditCommitMessage]]"
+                    >Edit</gr-button
                   >
-                    [[_change.change_id]]
-                  </span>
-                </div>
+                  <div
+                    class="changeId"
+                    hidden$="[[!_changeIdCommitMessageError]]"
+                  >
+                    <hr />
+                    Change-Id:
+                    <span
+                      class$="[[_computeChangeIdClass(_changeIdCommitMessageError)]]"
+                      title$="[[_computeTitleAttributeWarning(_changeIdCommitMessageError)]]"
+                    >
+                      [[_change.change_id]]
+                    </span>
+                  </div>
+                </template>
               </div>
-              <div
-                id="commitCollapseToggle"
-                class="collapseToggleContainer"
-                hidden$="[[!_commitCollapsible]]"
-              >
-                <gr-button
-                  link=""
-                  id="commitCollapseToggleButton"
-                  class="collapseToggleButton"
-                  on-click="_toggleCommitCollapsed"
+              <template is="dom-if" if="[[!_isNewChangeSummaryUiEnabled]]">
+                <div
+                  id="commitCollapseToggle"
+                  class="collapseToggleContainer"
+                  hidden$="[[!_commitCollapsible]]"
                 >
-                  [[_computeCollapseText(_commitCollapsed)]]
-                </gr-button>
-              </div>
+                  <gr-button
+                    link=""
+                    id="commitCollapseToggleButton"
+                    class="collapseToggleButton"
+                    on-click="_toggleCommitCollapsed"
+                  >
+                    [[_computeCollapseText(_commitCollapsed)]]
+                  </gr-button>
+                </div>
+              </template>
+              <gr-change-summary
+                class$="new-change-summary-[[_isNewChangeSummaryUiEnabled]]"
+                change-comments="[[_changeComments]]"
+                comment-threads="[[_commentThreads]]"
+                self-account="[[_account]]"
+              >
+              </gr-change-summary>
               <gr-endpoint-decorator name="commit-container">
                 <gr-endpoint-param name="change" value="[[_change]]">
                 </gr-endpoint-param>
@@ -517,27 +544,36 @@
               </gr-endpoint-decorator>
             </div>
             <div class="relatedChanges">
-              <gr-related-changes-list
-                id="relatedChanges"
-                class$="[[_computeRelatedChangesClass(_relatedChangesCollapsed)]]"
-                change="[[_change]]"
-                mergeable="[[_mergeable]]"
-                has-parent="{{hasParent}}"
-                on-update="_updateRelatedChangeMaxHeight"
-                patch-num="[[_computeLatestPatchNum(_allPatchSets)]]"
-                on-new-section-loaded="_computeShowRelatedToggle"
-              >
-              </gr-related-changes-list>
-              <div id="relatedChangesToggle" class="collapseToggleContainer">
-                <gr-button
-                  link=""
-                  id="relatedChangesToggleButton"
-                  class="collapseToggleButton"
-                  on-click="_toggleRelatedChangesCollapsed"
+              <template is="dom-if" if="[[_isNewChangeSummaryUiEnabled]]">
+                <gr-related-changes-list-experimental
+                  change="[[_change]]"
+                  id="relatedChangesExperimental"
+                  patch-num="[[_computeLatestPatchNum(_allPatchSets)]]"
+                ></gr-related-changes-list-experimental>
+              </template>
+              <template is="dom-if" if="[[!_isNewChangeSummaryUiEnabled]]">
+                <gr-related-changes-list
+                  id="relatedChanges"
+                  class$="[[_computeRelatedChangesClass(_relatedChangesCollapsed)]]"
+                  change="[[_change]]"
+                  mergeable="[[_mergeable]]"
+                  has-parent="{{hasParent}}"
+                  on-update="_updateRelatedChangeMaxHeight"
+                  patch-num="[[_computeLatestPatchNum(_allPatchSets)]]"
+                  on-new-section-loaded="_computeShowRelatedToggle"
                 >
-                  [[_computeCollapseText(_relatedChangesCollapsed)]]
-                </gr-button>
-              </div>
+                </gr-related-changes-list>
+                <div id="relatedChangesToggle" class="collapseToggleContainer">
+                  <gr-button
+                    link=""
+                    id="relatedChangesToggleButton"
+                    class="collapseToggleButton"
+                    on-click="_toggleRelatedChangesCollapsed"
+                  >
+                    [[_computeCollapseText(_relatedChangesCollapsed)]]
+                  </gr-button>
+                </div>
+              </template>
             </div>
           </div>
         </div>
@@ -558,6 +594,11 @@
           <span>Comments</span></gr-tooltip-content
         >
       </paper-tab>
+      <template is="dom-if" if="[[_showChecksTab]]">
+        <paper-tab data-name$="[[_constants.PrimaryTab.CHECKS]]"
+          >Checks</paper-tab
+        >
+      </template>
       <template
         is="dom-repeat"
         items="[[_dynamicTabHeaderEndpoints]]"
@@ -617,9 +658,7 @@
           change-num="[[_changeNum]]"
           patch-range="{{_patchRange}}"
           change-comments="[[_changeComments]]"
-          drafts="[[_diffDrafts]]"
           revisions="[[_change.revisions]]"
-          project-config="[[_projectConfig]]"
           selected-index="{{viewState.selectedFileIndex}}"
           diff-view-mode="[[viewState.diffMode]]"
           edit-mode="[[_editMode]]"
@@ -641,13 +680,23 @@
           change="[[_change]]"
           change-num="[[_changeNum]]"
           logged-in="[[_loggedIn]]"
+          comment-tab-state="[[_tabState.commentTab]]"
           only-show-robot-comments-with-human-reply=""
-          on-thread-list-modified="_handleReloadDiffComments"
           unresolved-only
+          show-comment-context
         ></gr-thread-list>
       </template>
       <template
         is="dom-if"
+        if="[[_isTabActive(_constants.PrimaryTab.CHECKS, _activeTabs)]]"
+      >
+        <gr-checks-tab
+          id="checksTab"
+          tab-state="[[_tabState.checksTab]]"
+        ></gr-checks-tab>
+      </template>
+      <template
+        is="dom-if"
         if="[[_isTabActive(_constants.PrimaryTab.FINDINGS, _activeTabs)]]"
       >
         <gr-dropdown-list
@@ -664,7 +713,6 @@
           logged-in="[[_loggedIn]]"
           hide-toggle-buttons
           empty-thread-msg="[[_messages.NO_ROBOT_COMMENTS_THREADS_MSG]]"
-          on-thread-list-modified="_handleReloadDiffComments"
         >
         </gr-thread-list>
         <template is="dom-if" if="[[_showRobotCommentsButton]]">
@@ -763,6 +811,7 @@
     no-cancel-on-esc-key=""
     scroll-action="lock"
     with-backdrop=""
+    on-iron-overlay-canceled="onReplyOverlayCanceled"
   >
     <gr-reply-dialog
       id="replyDialog"
@@ -781,7 +830,5 @@
     >
     </gr-reply-dialog>
   </gr-overlay>
-  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   <gr-comment-api id="commentAPI"></gr-comment-api>
 `;
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
index 8d06a03..ae446cd 100644
--- 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
@@ -30,14 +30,16 @@
 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 {GerritNav} 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 {EventType, PluginApi} from '../../../api/plugin';
 
 import 'lodash/lodash';
-import {TestKeyboardShortcutBinder} from '../../../test/test-utils';
-import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util';
+import {
+  stubRestApi,
+  TestKeyboardShortcutBinder,
+} from '../../../test/test-utils';
 import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {
   createAppElementChangeViewParams,
@@ -72,7 +74,6 @@
   GitRef,
   NumericChangeId,
   ParentPatchSetNum,
-  ParsedJSON,
   PatchRange,
   PatchSetNum,
   RevisionInfo,
@@ -91,7 +92,7 @@
   SinonSpy,
   SinonStubbedMember,
 } from 'sinon/pkg/sinon-esm';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
 import {CustomKeyboardEvent} from '../../../types/events';
 import {
   CommentThread,
@@ -100,8 +101,11 @@
   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';
+import {GerritView} from '../../../services/router/router-model';
+import {ParsedChangeInfo} from '../../../types/types';
+import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
+import {appContext} from '../../../services/app-context';
 
 const pluginApi = _testOnly_initGerritPluginApi();
 const fixture = fixtureFromElement('gr-change-view');
@@ -144,7 +148,7 @@
     {
       comments: [
         {
-          __path: '/COMMIT_MSG',
+          path: '/COMMIT_MSG',
           author: {
             _account_id: 1000000 as AccountId,
             name: 'user',
@@ -159,7 +163,7 @@
           unresolved: true,
         },
         {
-          __path: '/COMMIT_MSG',
+          path: '/COMMIT_MSG',
           author: {
             _account_id: 1000000 as AccountId,
             name: 'user',
@@ -190,11 +194,12 @@
       path: '/COMMIT_MSG',
       line: 5,
       rootId: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+      commentSide: CommentSide.REVISION,
     },
     {
       comments: [
         {
-          __path: '/COMMIT_MSG',
+          path: '/COMMIT_MSG',
           author: {
             _account_id: 1000000 as AccountId,
             name: 'user',
@@ -209,7 +214,7 @@
           unresolved: true,
         },
         {
-          __path: 'test.txt',
+          path: 'test.txt',
           author: {
             _account_id: 1000000 as AccountId,
             name: 'user',
@@ -231,7 +236,7 @@
     {
       comments: [
         {
-          __path: '/COMMIT_MSG',
+          path: '/COMMIT_MSG',
           author: {
             _account_id: 1000000 as AccountId,
             name: 'user',
@@ -249,11 +254,12 @@
       path: '/COMMIT_MSG',
       line: 4,
       rootId: '8caddf38_44770ec1' as UrlEncodedCommentId,
+      commentSide: CommentSide.REVISION,
     },
     {
       comments: [
         {
-          __path: '/COMMIT_MSG',
+          path: '/COMMIT_MSG',
           author: {
             _account_id: 1000000 as AccountId,
             name: 'user',
@@ -271,6 +277,7 @@
       path: '/COMMIT_MSG',
       line: 4,
       rootId: 'scaddf38_44770ec1' as UrlEncodedCommentId,
+      commentSide: CommentSide.REVISION,
     },
     {
       comments: [
@@ -291,11 +298,12 @@
       path: '/COMMIT_MSG',
       line: 6,
       rootId: 'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+      commentSide: CommentSide.REVISION,
     },
     {
       comments: [
         {
-          __path: '/COMMIT_MSG',
+          path: '/COMMIT_MSG',
           author: {
             _account_id: 1000000 as AccountId,
             name: 'user',
@@ -314,11 +322,12 @@
       path: '/COMMIT_MSG',
       line: 5,
       rootId: 'rc1' as UrlEncodedCommentId,
+      commentSide: CommentSide.REVISION,
     },
     {
       comments: [
         {
-          __path: '/COMMIT_MSG',
+          path: '/COMMIT_MSG',
           author: {
             _account_id: 1000000 as AccountId,
             name: 'user',
@@ -333,7 +342,7 @@
           robot_id: 'rc2' as RobotId,
         },
         {
-          __path: '/COMMIT_MSG',
+          path: '/COMMIT_MSG',
           author: {
             _account_id: 1000000 as AccountId,
             name: 'user',
@@ -351,6 +360,7 @@
       path: '/COMMIT_MSG',
       line: 5,
       rootId: 'rc2' as UrlEncodedCommentId,
+      commentSide: CommentSide.REVISION,
     },
   ];
 
@@ -359,29 +369,19 @@
     _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);
-      },
-    });
+    stubRestApi('getConfig').returns(
+      Promise.resolve({
+        ...createServerInfo(),
+        user: {
+          ...createUserConfig(),
+          anonymous_coward_name: 'test coward name',
+        },
+      })
+    );
+    stubRestApi('getAccount').returns(Promise.resolve(undefined));
+    stubRestApi('getDiffComments').returns(Promise.resolve({}));
+    stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+    stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
     element = fixture.instantiate();
     element._changeNum = 1 as NumericChangeId;
     sinon.stub(element.$.actions, 'reload').returns(Promise.resolve());
@@ -698,7 +698,7 @@
         messages: createChangeMessages(1),
       };
       element._change.labels = {};
-      sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+      stubRestApi('getChangeDetail').callsFake(() =>
         Promise.resolve({
           ...createChange(),
           // element has latest info
@@ -1105,7 +1105,10 @@
           },
         },
       };
-      sinon.stub(element.$.relatedChanges, 'reload');
+      const relatedChanges = element.shadowRoot!.querySelector(
+        '#relatedChanges'
+      ) as GrRelatedChangesList;
+      sinon.stub(relatedChanges, 'reload');
       sinon.stub(element, '_reload').returns(Promise.resolve([]));
       sinon.spy(element, '_paramsChanged');
       element.params = createAppElementChangeViewParams();
@@ -1413,15 +1416,17 @@
   });
 
   test('change num change', () => {
+    const change = {
+      ...createChange(),
+      labels: {},
+    } as ParsedChangeInfo;
+    stubRestApi('getChangeDetail').returns(Promise.resolve(change));
     element._changeNum = undefined;
     element._patchRange = {
       basePatchNum: ParentPatchSetNum,
       patchNum: 2 as PatchSetNum,
     };
-    element._change = {
-      ...createChange(),
-      labels: {},
-    };
+    element._change = change;
     element.viewState.changeNum = null;
     element.viewState.diffMode = DiffViewMode.UNIFIED;
     assert.equal(element.viewState.numFilesShown, 200);
@@ -1467,9 +1472,7 @@
   });
 
   test('diffMode defaults to side by side without preferences', done => {
-    sinon
-      .stub(element.$.restAPI, 'getPreferences')
-      .returns(Promise.resolve(createPreferences()));
+    stubRestApi('getPreferences').returns(Promise.resolve(createPreferences()));
     // No user prefs or diff view mode set.
 
     element._setDiffViewMode()!.then(() => {
@@ -1479,7 +1482,7 @@
   });
 
   test('diffMode defaults to preference when not already set', done => {
-    sinon.stub(element.$.restAPI, 'getPreferences').returns(
+    stubRestApi('getPreferences').returns(
       Promise.resolve({
         ...createPreferences(),
         default_diff_view: DiffViewMode.UNIFIED,
@@ -1494,7 +1497,7 @@
 
   test('existing diffMode overrides preference', done => {
     element.viewState.diffMode = DiffViewMode.SIDE_BY_SIDE;
-    sinon.stub(element.$.restAPI, 'getPreferences').returns(
+    stubRestApi('getPreferences').returns(
       Promise.resolve({
         ...createPreferences(),
         default_diff_view: DiffViewMode.UNIFIED,
@@ -1512,8 +1515,12 @@
       .callsFake(() => Promise.resolve([]));
     const reloadPatchDependentStub = sinon
       .stub(element, '_reloadPatchNumDependentResources')
-      .callsFake(() => Promise.resolve([undefined, undefined]));
-    const relatedClearSpy = sinon.spy(element.$.relatedChanges, 'clear');
+      .callsFake(() => Promise.resolve([undefined, undefined, undefined]));
+    flush();
+    const relatedChanges = element.shadowRoot!.querySelector(
+      '#relatedChanges'
+    ) as GrRelatedChangesList;
+    const relatedClearSpy = sinon.spy(relatedChanges, 'clear');
     const collapseStub = sinon.stub(element.$.fileList, 'collapseAllDiffs');
 
     const value: AppElementChangeViewParams = {
@@ -1536,6 +1543,36 @@
     assert.isTrue(collapseStub.calledTwice);
   });
 
+  test('reload ported comments when patchNum changes', () => {
+    sinon.stub(element, '_reload').callsFake(() => Promise.resolve([]));
+    sinon.stub(element, '_getCommitInfo');
+    sinon.stub(element.$.fileList, 'reload');
+    flush();
+    const reloadPortedCommentsStub = sinon.stub(
+      element.$.commentAPI,
+      'reloadPortedComments'
+    );
+    const relatedChanges = element.shadowRoot!.querySelector(
+      '#relatedChanges'
+    ) as GrRelatedChangesList;
+    sinon.spy(relatedChanges, 'clear');
+    sinon.stub(element.$.fileList, 'collapseAllDiffs');
+
+    const value: AppElementChangeViewParams = {
+      ...createAppElementChangeViewParams(),
+      view: GerritView.CHANGE,
+      patchNum: 1 as PatchSetNum,
+    };
+    element._paramsChanged(value);
+
+    element._initialLoadComplete = true;
+
+    value.basePatchNum = 1 as PatchSetNum;
+    value.patchNum = 2 as PatchSetNum;
+    element._paramsChanged(value);
+    assert.isTrue(reloadPortedCommentsStub.calledOnce);
+  });
+
   test('reload entire page when patchRange doesnt change', () => {
     const reloadStub = sinon
       .stub(element, '_reload')
@@ -1552,7 +1589,11 @@
 
   test('related changes are not updated after other action', done => {
     sinon.stub(element, '_reload').callsFake(() => Promise.resolve([]));
-    sinon.stub(element.$.relatedChanges, 'reload');
+    flush();
+    const relatedChanges = element.shadowRoot!.querySelector(
+      '#relatedChanges'
+    ) as GrRelatedChangesList;
+    sinon.stub(relatedChanges, 'reload');
     element._reload(true).then(() => {
       assert.isFalse(navigateToChangeStub.called);
       done();
@@ -1640,9 +1681,9 @@
   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 putStub = stubRestApi('putChangeCommitMessage').returns(
+      Promise.resolve(new Response(null, {status: 500}))
+    );
 
     const mockEvent = (content: string) => {
       return new CustomEvent('', {detail: {content}});
@@ -1758,7 +1799,7 @@
 
   test('topic is coalesced to null', done => {
     sinon.stub(element, '_changeChanged');
-    sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+    stubRestApi('getChangeDetail').returns(
       Promise.resolve({
         ...createChange(),
         labels: {},
@@ -1775,7 +1816,7 @@
 
   test('commit sha is populated from getChangeDetail', done => {
     sinon.stub(element, '_changeChanged');
-    sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+    stubRestApi('getChangeDetail').callsFake(() =>
       Promise.resolve({
         ...createChange(),
         labels: {},
@@ -1793,7 +1834,7 @@
   test('edit is added to change', () => {
     sinon.stub(element, '_changeChanged');
     const changeRevision = createRevision();
-    sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+    stubRestApi('getChangeDetail').callsFake(() =>
       Promise.resolve({
         ...createChange(),
         labels: {},
@@ -1950,9 +1991,7 @@
   });
 
   test('revert dialog opened with revert param', done => {
-    sinon
-      .stub(element.$.restAPI, 'getLoggedIn')
-      .callsFake(() => Promise.resolve(true));
+    stubRestApi('getLoggedIn').returns(Promise.resolve(true));
     const awaitPluginsLoadedStub = sinon
       .stub(getPluginLoader(), 'awaitPluginsLoaded')
       .callsFake(() => Promise.resolve());
@@ -2028,7 +2067,7 @@
         messages: createChangeMessages(1),
       };
       element._change.labels = {};
-      sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+      stubRestApi('getChangeDetail').callsFake(() =>
         Promise.resolve({
           ...createChange(),
           // element has latest info
@@ -2120,7 +2159,7 @@
         messages: createChangeMessages(1),
       };
       element._change.labels = {};
-      sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+      stubRestApi('getChangeDetail').callsFake(() =>
         Promise.resolve({
           ...createChange(),
           // new patchset was uploaded
@@ -2133,12 +2172,19 @@
 
     test('commitCollapseToggle hidden for short commit message', () => {
       element._latestCommitMessage = '';
-      assert.isTrue(element.$.commitCollapseToggle.hasAttribute('hidden'));
+      flush();
+      const commitCollapseToggle = element.shadowRoot!.querySelector(
+        '#commitCollapseToggle'
+      );
+      assert.isTrue(commitCollapseToggle?.hasAttribute('hidden'));
     });
 
     test('commitCollapseToggle shown for long commit message', () => {
       element._latestCommitMessage = _.times(31, String).join('\n');
-      assert.isFalse(element.$.commitCollapseToggle.hasAttribute('hidden'));
+      const commitCollapseToggle = element.shadowRoot!.querySelector(
+        '#commitCollapseToggle'
+      );
+      assert.isFalse(commitCollapseToggle?.hasAttribute('hidden'));
     });
 
     test('commitCollapseToggle functions', () => {
@@ -2146,7 +2192,10 @@
       assert.isTrue(element._commitCollapsed);
       assert.isTrue(element._commitCollapsible);
       assert.isTrue(element.$.commitMessageEditor.hasAttribute('collapsed'));
-      tap(element.$.commitCollapseToggleButton);
+      const commitCollapseToggleButton = element.shadowRoot!.querySelector(
+        '#commitCollapseToggleButton'
+      )!;
+      tap(commitCollapseToggleButton);
       assert.isFalse(element._commitCollapsed);
       assert.isTrue(element._commitCollapsible);
       assert.isFalse(element.$.commitMessageEditor.hasAttribute('collapsed'));
@@ -2160,40 +2209,40 @@
     });
 
     test('relatedChangesToggle shown height greater than changeInfo height', () => {
-      assert.isFalse(
-        element.$.relatedChangesToggle.classList.contains('showToggle')
+      const relatedChangesToggle = element.shadowRoot!.querySelector(
+        '#relatedChangesToggle'
       );
+      assert.isFalse(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')
-      );
+      const relatedChanges = element.shadowRoot!.querySelector(
+        '#relatedChanges'
+      ) as GrRelatedChangesList;
+      relatedChanges.dispatchEvent(new CustomEvent('new-section-loaded'));
+      assert.isTrue(relatedChangesToggle!.classList.contains('showToggle'));
       assert.equal(updateHeightSpy.callCount, 1);
     });
 
     test('relatedChangesToggle hidden height less than changeInfo height', () => {
-      assert.isFalse(
-        element.$.relatedChangesToggle.classList.contains('showToggle')
+      const relatedChangesToggle = element.shadowRoot!.querySelector(
+        '#relatedChangesToggle'
       );
+      assert.isFalse(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')
-      );
+      const relatedChanges = element.shadowRoot!.querySelector(
+        '#relatedChanges'
+      ) as GrRelatedChangesList;
+      relatedChanges.dispatchEvent(new CustomEvent('new-section-loaded'));
+      assert.isFalse(relatedChangesToggle!.classList.contains('showToggle'));
       assert.equal(updateHeightSpy.callCount, 1);
     });
 
@@ -2203,10 +2252,16 @@
         return {matches: false} as MediaQueryList;
       });
       assert.isTrue(element._relatedChangesCollapsed);
-      assert.isTrue(element.$.relatedChanges.classList.contains('collapsed'));
-      tap(element.$.relatedChangesToggleButton);
+      const relatedChangesToggleButton = element.shadowRoot!.querySelector(
+        '#relatedChangesToggleButton'
+      );
+      const relatedChanges = element.shadowRoot!.querySelector(
+        '#relatedChanges'
+      ) as GrRelatedChangesList;
+      assert.isTrue(relatedChanges.classList.contains('collapsed'));
+      tap(relatedChangesToggleButton!);
       assert.isFalse(element._relatedChangesCollapsed);
-      assert.isFalse(element.$.relatedChanges.classList.contains('collapsed'));
+      assert.isFalse(relatedChanges.classList.contains('collapsed'));
     });
 
     test('_updateRelatedChangeMaxHeight without commit toggle', () => {
@@ -2299,17 +2354,15 @@
       });
 
       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,
-            })
-          );
+        const getChangeDetailStub = stubRestApi('getChangeDetail').returns(
+          Promise.resolve({
+            ...createChange(),
+            // element has latest info
+            revisions: {rev1: createRevision()},
+            messages: createChangeMessages(1),
+            current_revision: 'rev1' as CommitId,
+          })
+        );
 
         element._serverConfig = {
           ...createServerInfo(),
@@ -2321,9 +2374,8 @@
       });
 
       test('_startUpdateCheckTimer up-to-date', async () => {
-        const getChangeDetailStub = sinon
-          .stub(element.$.restAPI, 'getChangeDetail')
-          .callsFake(() =>
+        const getChangeDetailStub = stubRestApi('getChangeDetail').callsFake(
+          () =>
             Promise.resolve({
               ...createChange(),
               // element has latest info
@@ -2331,7 +2383,7 @@
               messages: createChangeMessages(1),
               current_revision: 'rev1' as CommitId,
             })
-          );
+        );
 
         element._serverConfig = {
           ...createServerInfo(),
@@ -2345,7 +2397,7 @@
       });
 
       test('_startUpdateCheckTimer out-of-date shows an alert', done => {
-        sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+        stubRestApi('getChangeDetail').callsFake(() =>
           Promise.resolve({
             ...createChange(),
             // new patchset was uploaded
@@ -2368,7 +2420,7 @@
       });
 
       test('_startUpdateCheckTimer respects _loading', async () => {
-        sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+        stubRestApi('getChangeDetail').callsFake(() =>
           Promise.resolve({
             ...createChange(),
             // new patchset was uploaded
@@ -2390,7 +2442,7 @@
       });
 
       test('_startUpdateCheckTimer new status shows an alert', done => {
-        sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+        stubRestApi('getChangeDetail').callsFake(() =>
           Promise.resolve({
             ...createChange(),
             // element has latest info
@@ -2412,7 +2464,7 @@
       });
 
       test('_startUpdateCheckTimer new messages shows an alert', done => {
-        sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+        stubRestApi('getChangeDetail').callsFake(() =>
           Promise.resolve({
             ...createChange(),
             revisions: {rev1: createRevision()},
@@ -2480,7 +2532,11 @@
   });
 
   test('topic update reloads related changes', () => {
-    const reloadStub = sinon.stub(element.$.relatedChanges, 'reload');
+    flush();
+    const relatedChanges = element.shadowRoot!.querySelector(
+      '#relatedChanges'
+    ) as GrRelatedChangesList;
+    const reloadStub = sinon.stub(relatedChanges, 'reload');
     element.dispatchEvent(new CustomEvent('topic-changed'));
     assert.isTrue(reloadStub.calledOnce);
   });
@@ -2654,7 +2710,7 @@
   test('_selectedRevision updates when patchNum is changed', () => {
     const revision1: RevisionInfo = createRevision(1);
     const revision2: RevisionInfo = createRevision(2);
-    sinon.stub(element.$.restAPI, 'getChangeDetail').returns(
+    stubRestApi('getChangeDetail').returns(
       Promise.resolve({
         ...createChange(),
         revisions: {
@@ -2683,7 +2739,7 @@
     const revision1 = createRevision(1);
     const revision2 = createRevision(2);
     const revision3 = createEditRevision();
-    sinon.stub(element.$.restAPI, 'getChangeDetail').returns(
+    stubRestApi('getChangeDetail').returns(
       Promise.resolve({
         ...createChange(),
         revisions: {
@@ -2711,7 +2767,7 @@
     element._change = {...change};
     element._patchRange = {patchNum: 4 as PatchSetNum};
     element._mergeable = true;
-    const showStub = sinon.stub(element.$.jsAPI, 'handleEvent');
+    const showStub = sinon.stub(appContext.jsApiService, 'handleEvent');
     element._sendShowChangeEvent();
     assert.isTrue(showStub.calledOnce);
     assert.equal(showStub.lastCall.args[0], EventType.SHOW_CHANGE);
@@ -2745,7 +2801,7 @@
       });
 
       element.set('_change.revisions.rev2', {
-        _number: SPECIAL_PATCH_SET_NUM.EDIT,
+        _number: EditPatchSetNum,
       });
       flush();
 
@@ -2832,9 +2888,9 @@
     let getMergeableStub: SinonStubbedMember<RestApiService['getMergeable']>;
     setup(() => {
       element._change = {...createChange(), labels: {}};
-      getMergeableStub = sinon
-        .stub(element.$.restAPI, 'getMergeable')
-        .returns(Promise.resolve({...createMergeable(), mergeable: true}));
+      getMergeableStub = stubRestApi('getMergeable').returns(
+        Promise.resolve({...createMergeable(), mergeable: true})
+      );
     });
 
     test('merged change', () => {
@@ -2865,18 +2921,20 @@
   });
 
   test('_paramsChanged sets in projectLookup', () => {
-    sinon.stub(element.$.relatedChanges, 'reload');
+    flush();
+    const relatedChanges = element.shadowRoot!.querySelector(
+      '#relatedChanges'
+    ) as GrRelatedChangesList;
+    sinon.stub(relatedChanges, 'reload');
     sinon.stub(element, '_reload').returns(Promise.resolve([]));
-    const setStub = sinon.stub(element.$.restAPI, 'setInProjectLookup');
+    const setStub = stubRestApi('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)
-    );
+    assert.isTrue(setStub.calledWith(101 as never, TEST_PROJECT_NAME as never));
   });
 
   test('_handleToggleStar called when star is tapped', () => {
@@ -2904,15 +2962,18 @@
       sinon.stub(element, '_reloadComments').returns(Promise.resolve());
       sinon.stub(element, '_getMergeability').returns(Promise.resolve());
       sinon.stub(element, '_getLatestCommitMessage').returns(Promise.resolve());
+      sinon
+        .stub(element, '_reloadPatchNumDependentResources')
+        .returns(Promise.resolve([undefined, undefined, undefined]));
     });
 
-    test("don't report changedDisplayed on reply", done => {
+    test("don't report changeDisplayed on reply", done => {
       const changeDisplayStub = sinon.stub(
-        element.reporting,
+        appContext.reportingService,
         'changeDisplayed'
       );
       const changeFullyLoadedStub = sinon.stub(
-        element.reporting,
+        appContext.reportingService,
         'changeFullyLoaded'
       );
       element._handleReplySent();
@@ -2923,13 +2984,13 @@
       });
     });
 
-    test('report changedDisplayed on _paramsChanged', done => {
+    test('report changeDisplayed on _paramsChanged', done => {
       const changeDisplayStub = sinon.stub(
-        element.reporting,
+        appContext.reportingService,
         'changeDisplayed'
       );
       const changeFullyLoadedStub = sinon.stub(
-        element.reporting,
+        appContext.reportingService,
         'changeFullyLoaded'
       );
       element._paramsChanged({
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
index e05bac0..ebcabbf 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
@@ -19,7 +19,6 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-dialog/gr-dialog';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
@@ -32,16 +31,14 @@
   RepoName,
   BranchName,
   CommitId,
+  ChangeInfoId,
 } from '../../../types/common';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {customElement, property, observe} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {
-  GrAutocomplete,
-  AutocompleteSuggestion,
-} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {HttpMethod, ChangeStatus} from '../../../constants/constants';
-import {hasOwnProperty} from '../../../utils/common-util';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {fireEvent} from '../../../utils/event-util';
 
 const SUGGESTIONS_LIMIT = 15;
 const CHANGE_SUBJECT_LIMIT = 50;
@@ -50,11 +47,18 @@
   TOPIC,
 }
 
+// These values are directly displayed in the dialog to show progress of change
+enum ProgressStatus {
+  RUNNING = 'RUNNING',
+  FAILED = 'FAILED',
+  NOT_STARTED = 'NOT STARTED',
+  SUCCESSFUL = 'SUCCESSFUL',
+}
+
 type Statuses = {[changeId: string]: Status};
 
-// TODO(TS): maybe convert status to an enum
 interface Status {
-  status: string;
+  status: ProgressStatus;
   msg?: string;
 }
 
@@ -64,12 +68,9 @@
   }
 }
 
-// TODO(TS): add type after gr-autocomplete and gr-rest-api-interface
-// is converted
 export interface GrConfirmCherrypickDialog {
   $: {
-    restAPI: RestApiService & Element;
-    branchInput: GrAutocomplete;
+    branchInput: HTMLElement;
   };
 }
 
@@ -142,6 +143,10 @@
   @property({type: Object})
   reporting: ReportingService;
 
+  private selectedChangeIds = new Set<ChangeInfoId>();
+
+  private readonly restApiService = appContext.restApiService;
+
   constructor() {
     super();
     this._statuses = {};
@@ -155,6 +160,7 @@
     const projects: {[projectName: string]: boolean} = {};
     this._duplicateProjectChanges = false;
     changes.forEach(change => {
+      this.selectedChangeIds.add(change.id);
       if (projects[change.project]) {
         this._duplicateProjectChanges = true;
       }
@@ -172,6 +178,19 @@
     );
   }
 
+  _isChangeSelected(changeId: ChangeInfoId) {
+    return this.selectedChangeIds.has(changeId);
+  }
+
+  _toggleChangeSelected(e: Event) {
+    const changeId = ((dom(e) as EventApi).localTarget as HTMLElement).dataset[
+      'item'
+    ]! as ChangeInfoId;
+    if (this.selectedChangeIds.has(changeId))
+      this.selectedChangeIds.delete(changeId);
+    else this.selectedChangeIds.add(changeId);
+  }
+
   _computeTopicErrorMessage(duplicateProjectChanges: boolean) {
     if (duplicateProjectChanges) {
       return 'Two changes cannot be of the same project';
@@ -184,18 +203,19 @@
   }
 
   _computeStatus(change: ChangeInfo, statuses: Statuses) {
-    if (!change || !statuses || !statuses[change.id]) return 'NOT STARTED';
+    if (!change || !statuses || !statuses[change.id])
+      return ProgressStatus.NOT_STARTED;
     return statuses[change.id].status;
   }
 
   _computeStatusClass(change: ChangeInfo, statuses: Statuses) {
     if (!change || !statuses || !statuses[change.id]) return '';
-    return statuses[change.id].status === 'FAILED' ? 'error' : '';
+    return statuses[change.id].status === ProgressStatus.FAILED ? 'error' : '';
   }
 
   _computeError(change: ChangeInfo, statuses: Statuses) {
     if (!change || !statuses || !statuses[change.id]) return '';
-    if (statuses[change.id].status === 'FAILED') {
+    if (statuses[change.id].status === ProgressStatus.FAILED) {
       return statuses[change.id].msg;
     }
     return '';
@@ -213,7 +233,7 @@
 
   _computeCancelLabel(statuses: Statuses) {
     const isRunningChange = Object.values(statuses).some(
-      v => v.status === 'RUNNING'
+      v => v.status === ProgressStatus.RUNNING
     );
     return isRunningChange ? 'Close' : 'Cancel';
   }
@@ -221,14 +241,16 @@
   _computeDisableCherryPick(
     cherryPickType: CherryPickType,
     duplicateProjectChanges: boolean,
-    statuses: Statuses
+    statuses: Statuses,
+    branch?: BranchName
   ) {
+    if (!branch) return true;
     const duplicateProject =
       cherryPickType === CherryPickType.TOPIC && duplicateProjectChanges;
     if (duplicateProject) return true;
     if (!statuses) return false;
     const isRunningChange = Object.values(statuses).some(
-      v => v.status === 'RUNNING'
+      v => v.status === ProgressStatus.RUNNING
     );
     return isRunningChange;
   }
@@ -243,10 +265,12 @@
 
   _handlecherryPickSingleChangeClicked() {
     this._cherryPickType = CherryPickType.SINGLE_CHANGE;
+    fireEvent(this, 'iron-resize');
   }
 
   _handlecherryPickTopicClicked() {
     this._cherryPickType = CherryPickType.TOPIC;
+    fireEvent(this, 'iron-resize');
   }
 
   @observe('changeStatus', 'commitNum', 'commitMessage')
@@ -281,14 +305,22 @@
   _handleCherryPickFailed(change: ChangeInfo, response?: Response | null) {
     if (!response) return;
     response.text().then((errText: string) => {
-      this.updateStatus(change, {status: 'FAILED', msg: errText});
+      this.updateStatus(change, {status: ProgressStatus.FAILED, msg: errText});
     });
   }
 
   _handleCherryPickTopic() {
-    const topic = this._generateRandomCherryPickTopic(this.changes[0]);
-    this.changes.forEach(change => {
-      this.updateStatus(change, {status: 'RUNNING'});
+    const changes = this.changes.filter(change =>
+      this.selectedChangeIds.has(change.id)
+    );
+    if (!changes.length) {
+      const errorSpan = this.shadowRoot?.querySelector('.error-message');
+      errorSpan!.innerHTML = 'No change selected';
+      return;
+    }
+    const topic = this._generateRandomCherryPickTopic(changes[0]);
+    changes.forEach(change => {
+      this.updateStatus(change, {status: ProgressStatus.RUNNING});
       const payload = {
         destination: this.branch,
         base: null,
@@ -301,7 +333,7 @@
       };
       // revisions and current_revision must exist hence casting
       const patchNum = change.revisions![change.current_revision!]._number;
-      this.$.restAPI
+      this.restApiService
         .executeChangeAction(
           change._number,
           HttpMethod.POST,
@@ -311,9 +343,9 @@
           handleError
         )
         .then(() => {
-          this.updateStatus(change, {status: 'SUCCESSFUL'});
+          this.updateStatus(change, {status: ProgressStatus.SUCCESSFUL});
           const failedOrPending = Object.values(this._statuses).find(
-            v => v.status !== 'SUCCESSFUL'
+            v => v.status !== ProgressStatus.SUCCESSFUL
           );
           if (!failedOrPending) {
             /* This needs some more work, as the new topic may not always be
@@ -360,30 +392,25 @@
     input: string
   ): Promise<AutocompleteSuggestion[]> {
     if (!this.project) {
-      console.error('no project specified');
+      this.reporting.error(new Error('no project specified'));
       return Promise.resolve([]);
     }
     if (input.startsWith('refs/heads/')) {
       input = input.substring('refs/heads/'.length);
     }
-    return this.$.restAPI
+    return this.restApiService
       .getRepoBranches(input, this.project, SUGGESTIONS_LIMIT)
       .then((response: BranchInfo[] | undefined) => {
-        const branches = [];
         if (!response) return [];
-        let branch;
-        for (const key in response) {
-          if (!hasOwnProperty(response, key)) {
-            continue;
-          }
-          if (response[key].ref.startsWith('refs/heads/')) {
-            branch = response[key].ref.substring('refs/heads/'.length);
+        const branches = [];
+        for (const branchInfo of response) {
+          let branch;
+          if (branchInfo.ref.startsWith('refs/heads/')) {
+            branch = branchInfo.ref.substring('refs/heads/'.length);
           } else {
-            branch = response[key].ref;
+            branch = branchInfo.ref;
           }
-          branches.push({
-            name: branch,
-          });
+          branches.push({name: branch});
         }
         return branches;
       });
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.ts
index 072f110..6c0099e 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.ts
@@ -46,11 +46,12 @@
     }
     .cherryPickTopicLayout {
       display: flex;
+      align-items: center;
+      margin-bottom: var(--spacing-m);
     }
     .cherryPickSingleChange,
     .cherryPickTopic {
       margin-left: var(--spacing-m);
-      margin-bottom: var(--spacing-m);
     }
     .cherry-pick-topic-message {
       margin-bottom: var(--spacing-m);
@@ -85,7 +86,7 @@
   <gr-dialog
     confirm-label="Cherry Pick"
     cancel-label="[[_computeCancelLabel(_statuses)]]"
-    disabled$="[[_computeDisableCherryPick(_cherryPickType, _duplicateProjectChanges, _statuses)]]"
+    disabled$="[[_computeDisableCherryPick(_cherryPickType, _duplicateProjectChanges, _statuses, branch)]]"
     on-confirm="_handleConfirmTap"
     on-cancel="_handleCancelTap"
   >
@@ -179,10 +180,12 @@
         <table>
           <thead>
             <tr>
+              <th></th>
               <th>Change</th>
+              <th>Status</th>
               <th>Subject</th>
               <th>Project</th>
-              <th>Status</th>
+              <th>Progress</th>
               <!-- Error Message -->
               <th></th>
             </tr>
@@ -190,7 +193,16 @@
           <tbody>
             <template is="dom-repeat" items="[[changes]]">
               <tr>
+                <td>
+                  <input
+                    type="checkbox"
+                    data-item$="[[item.id]]"
+                    on-change="_toggleChangeSelected"
+                    checked="[[_isChangeSelected(item.id)]]"
+                  />
+                </td>
                 <td><span> [[_getChangeId(item)]] </span></td>
+                <td><span> [[item.status]] </span></td>
                 <td>
                   <span> [[_getTrimmedChangeSubject(item.subject)]] </span>
                 </td>
@@ -212,5 +224,4 @@
       </template>
     </div>
   </gr-dialog>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js
index 07f8f63..536a4ab 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-confirm-cherrypick-dialog.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-confirm-cherrypick-dialog');
 
@@ -28,20 +29,18 @@
   let element;
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getRepoBranches(input) {
-        if (input.startsWith('test')) {
-          return Promise.resolve([
-            {
-              ref: 'refs/heads/test-branch',
-              revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
-              can_delete: true,
-            },
-          ]);
-        } else {
-          return Promise.resolve({});
-        }
-      },
+    stubRestApi('getRepoBranches').callsFake(input => {
+      if (input.startsWith('test')) {
+        return Promise.resolve([
+          {
+            ref: 'refs/heads/test-branch',
+            revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
+            can_delete: true,
+          },
+        ]);
+      } else {
+        return Promise.resolve([]);
+      }
     });
     element = basicFixture.instantiate();
     element.project = 'test-project';
@@ -78,16 +77,15 @@
     assert.equal(element.message, myNewMessage);
   });
 
-  test('_getProjectBranchesSuggestions empty', done => {
-    element._getProjectBranchesSuggestions('nonexistent').then(branches => {
-      assert.equal(branches.length, 0);
-      done();
-    });
+  test('_getProjectBranchesSuggestions empty', async () => {
+    const branches = await element._getProjectBranchesSuggestions('asdf');
+    assert.isEmpty(branches);
   });
 
   suite('cherry pick topic', () => {
     const changes = [
       {
+        id: '1234',
         change_id: '12345678901234', topic: 'T', subject: 'random',
         project: 'A',
         _number: 1,
@@ -97,6 +95,7 @@
         current_revision: 'a',
       },
       {
+        id: '5678',
         change_id: '23456', topic: 'T', subject: 'a'.repeat(100),
         project: 'B',
         _number: 2,
@@ -109,11 +108,12 @@
     setup(() => {
       element.updateChanges(changes);
       element._cherryPickType = CHERRY_PICK_TYPES.TOPIC;
+      flush();
     });
 
     test('cherry pick topic submit', done => {
       element.branch = 'master';
-      const executeChangeActionStub = sinon.stub(element.$.restAPI,
+      const executeChangeActionStub = stubRestApi(
           'executeChangeAction').returns(Promise.resolve([]));
       MockInteractions.tap(element.shadowRoot.
           querySelector('gr-dialog').$.confirm);
@@ -129,6 +129,38 @@
       });
     });
 
+    test('deselecting a change removes it from being cherry picked', () => {
+      element.branch = 'master';
+      const executeChangeActionStub = stubRestApi(
+          'executeChangeAction').returns(Promise.resolve([]));
+      const checkboxes = element.shadowRoot.querySelectorAll(
+          'input[type="checkbox"]');
+      assert.equal(checkboxes.length, 2);
+      assert.isTrue(checkboxes[0].checked);
+      MockInteractions.tap(checkboxes[0]);
+      MockInteractions.tap(element.shadowRoot.
+          querySelector('gr-dialog').$.confirm);
+      flush();
+      assert.equal(executeChangeActionStub.callCount, 1);
+    });
+
+    test('deselecting all change shows error message', () => {
+      element.branch = 'master';
+      const executeChangeActionStub = stubRestApi(
+          'executeChangeAction').returns(Promise.resolve([]));
+      const checkboxes = element.shadowRoot.querySelectorAll(
+          'input[type="checkbox"]');
+      assert.equal(checkboxes.length, 2);
+      MockInteractions.tap(checkboxes[0]);
+      MockInteractions.tap(checkboxes[1]);
+      MockInteractions.tap(element.shadowRoot.
+          querySelector('gr-dialog').$.confirm);
+      flush();
+      assert.equal(executeChangeActionStub.callCount, 0);
+      assert.equal(element.shadowRoot.querySelector('.error-message').innerText
+          , 'No change selected');
+    });
+
     test('_computeStatusClass', () => {
       assert.equal(element._computeStatusClass({id: 1}, {1: {status: 'RUNNING'},
       }), '');
@@ -139,6 +171,9 @@
     test('submit button is blocked while cherry picks is running', done => {
       const confirmButton = element.shadowRoot.querySelector('gr-dialog').$
           .confirm;
+      assert.isTrue(confirmButton.hasAttribute('disabled'));
+      element.branch = 'b';
+      flush();
       assert.isFalse(confirmButton.hasAttribute('disabled'));
       element.updateStatus(changes[0], {status: 'RUNNING'});
       flush(() => {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
index e9cf19e..5e95e66 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
@@ -17,24 +17,18 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-dialog/gr-dialog';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-confirm-move-dialog_html';
 import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {customElement, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {RepoName, BranchName} from '../../../types/common';
 import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {appContext} from '../../../services/app-context';
 
 const SUGGESTIONS_LIMIT = 15;
 
-export interface GrConfirmMoveDialog {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
 @customElement('gr-confirm-move-dialog')
 export class GrConfirmMoveDialog extends KeyboardShortcutMixin(
   GestureEventListeners(LegacyElementMixin(PolymerElement))
@@ -73,6 +67,8 @@
     };
   }
 
+  private readonly restApiService = appContext.restApiService;
+
   constructor() {
     super();
     this._query = () => this._getProjectBranchesSuggestions();
@@ -108,7 +104,7 @@
     if (input.startsWith('refs/heads/')) {
       input = input.substring('refs/heads/'.length);
     }
-    return this.$.restAPI
+    return this.restApiService
       .getRepoBranches(input, this.project, SUGGESTIONS_LIMIT)
       .then(response => {
         const branches: AutocompleteSuggestion[] = [];
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.ts
index b5b46d6..de75a39 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.ts
@@ -79,5 +79,4 @@
       ></iron-autogrow-textarea>
     </div>
   </gr-dialog>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.js
index 43bde75..db00f6b 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-confirm-move-dialog.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-confirm-move-dialog');
 
@@ -24,20 +25,18 @@
   let element;
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getRepoBranches(input) {
-        if (input.startsWith('test')) {
-          return Promise.resolve([
-            {
-              ref: 'refs/heads/test-branch',
-              revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
-              can_delete: true,
-            },
-          ]);
-        } else {
-          return Promise.resolve(undefined);
-        }
-      },
+    stubRestApi('getRepoBranches').callsFake(input => {
+      if (input.startsWith('test')) {
+        return Promise.resolve([
+          {
+            ref: 'refs/heads/test-branch',
+            revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
+            can_delete: true,
+          },
+        ]);
+      } else {
+        return Promise.resolve(undefined);
+      }
     });
     element = basicFixture.instantiate();
     element.project = 'test-project';
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
index db0e1ff..b2b6e61 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
@@ -16,21 +16,19 @@
  */
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-dialog/gr-dialog';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/shared-styles';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-confirm-rebase-dialog_html';
 import {customElement, property, observe} from '@polymer/decorators';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {NumericChangeId, BranchName} from '../../../types/common';
 import {
   GrAutocomplete,
   AutocompleteQuery,
   AutocompleteSuggestion,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {appContext} from '../../../services/app-context';
 
 interface RebaseChange {
   name: string;
@@ -43,7 +41,6 @@
 
 export interface GrConfirmRebaseDialog {
   $: {
-    restAPI: RestApiService & Element;
     parentInput: GrAutocomplete;
     rebaseOnParentInput: HTMLInputElement;
     rebaseOnOtherInput: HTMLInputElement;
@@ -92,6 +89,8 @@
   @property({type: Array})
   _recentChanges?: RebaseChange[];
 
+  private readonly restApiService = appContext.restApiService;
+
   constructor() {
     super();
     this._query = input => this._getChangeSuggestions(input);
@@ -104,18 +103,15 @@
   // in case there are new/updated changes in the generic query since the
   // last time it was run.
   fetchRecentChanges() {
-    return this.$.restAPI
+    return this.restApiService
       .getChanges(undefined, 'is:open -age:90d')
       .then(response => {
         if (!response) return [];
         const changes: RebaseChange[] = [];
-        for (const key in response) {
-          if (!hasOwnProperty(response, key)) {
-            continue;
-          }
+        for (const change of response) {
           changes.push({
-            name: `${response[key]._number}: ${response[key].subject}`,
-            value: response[key]._number,
+            name: `${change._number}: ${change.subject}`,
+            value: change._number,
           });
         }
         this._recentChanges = changes;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts
index 687d31f..7d28de6 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts
@@ -127,5 +127,4 @@
       </div>
     </div>
   </gr-dialog>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.js
index 8bce572..0faf604 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-confirm-rebase-dialog.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-confirm-rebase-dialog');
 
@@ -102,6 +103,7 @@
 
   suite('parent suggestions', () => {
     let recentChanges;
+    let getChangesStub;
     setup(() => {
       recentChanges = [
         {
@@ -118,7 +120,7 @@
         },
       ];
 
-      sinon.stub(element.$.restAPI, 'getChanges').returns(Promise.resolve(
+      getChangesStub = stubRestApi('getChanges').returns(Promise.resolve(
           [
             {
               _number: 123,
@@ -141,29 +143,26 @@
       return element._getRecentChanges()
           .then(() => {
             assert.deepEqual(element._recentChanges, recentChanges);
-            assert.equal(element.$.restAPI.getChanges.callCount, 1);
+            assert.equal(getChangesStub.callCount, 1);
             // When called a second time, should not re-request recent changes.
             element._getRecentChanges();
           })
           .then(() => {
             assert.equal(element._getRecentChanges.callCount, 2);
-            assert.equal(element.$.restAPI.getChanges.callCount, 1);
+            assert.equal(getChangesStub.callCount, 1);
           });
     });
 
     test('_filterChanges', () => {
       assert.equal(element._filterChanges('123', recentChanges).length, 1);
       assert.equal(element._filterChanges('12', recentChanges).length, 2);
-      assert.equal(element._filterChanges('awesome', recentChanges).length,
-          3);
-      assert.equal(element._filterChanges('third', recentChanges).length,
-          1);
+      assert.equal(element._filterChanges('awesome', recentChanges).length, 3);
+      assert.equal(element._filterChanges('third', recentChanges).length, 1);
 
       element.changeNumber = 123;
       assert.equal(element._filterChanges('123', recentChanges).length, 0);
       assert.equal(element._filterChanges('124', recentChanges).length, 1);
-      assert.equal(element._filterChanges('awesome', recentChanges).length,
-          2);
+      assert.equal(element._filterChanges('awesome', recentChanges).length, 2);
     });
 
     test('input text change triggers function', () => {
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..e10b12b 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
@@ -17,14 +17,14 @@
 import '../../shared/gr-dialog/gr-dialog';
 import '../../../styles/shared-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
-import '../../shared/gr-js-api-interface/gr-js-api-interface';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-confirm-revert-dialog_html';
 import {customElement, property} from '@polymer/decorators';
-import {JsApiService} from '../../shared/gr-js-api-interface/gr-js-api-types';
 import {ChangeInfo, CommitId} from '../../../types/common';
+import {fireAlert} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
 
 const ERR_COMMIT_NOT_FOUND = 'Unable to find the commit hash of this change.';
 const CHANGE_SUBJECT_LIMIT = 50;
@@ -40,11 +40,6 @@
   message?: string;
 }
 
-export interface GrConfirmRevertDialog {
-  $: {
-    jsAPI: JsApiService & Element;
-  };
-}
 @customElement('gr-confirm-revert-dialog')
 export class GrConfirmRevertDialog extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
@@ -92,6 +87,8 @@
   @property({type: Array})
   _revertMessages: string[] = [];
 
+  private readonly jsAPI = appContext.jsApiService;
+
   _computeIfSingleRevert(revertType: number) {
     return revertType === RevertType.REVERT_SINGLE_CHANGE;
   }
@@ -101,7 +98,7 @@
   }
 
   _modifyRevertMsg(change: ChangeInfo, commitMessage: string, message: string) {
-    return this.$.jsAPI.modifyRevertMsg(change, message, commitMessage);
+    return this.jsAPI.modifyRevertMsg(change, message, commitMessage);
   }
 
   populate(change: ChangeInfo, commitMessage: string, changes: ChangeInfo[]) {
@@ -124,13 +121,7 @@
     const originalTitle = (commitMessage || '').split('\n')[0];
     const revertTitle = `Revert "${originalTitle}"`;
     if (!commitHash) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: ERR_COMMIT_NOT_FOUND},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, ERR_COMMIT_NOT_FOUND);
       return;
     }
     const revertCommitText = `This reverts commit ${commitHash}.`;
@@ -157,7 +148,7 @@
     msg: string,
     commitMessage: string
   ) {
-    return this.$.jsAPI.modifyRevertSubmissionMsg(change, msg, commitMessage);
+    return this.jsAPI.modifyRevertSubmissionMsg(change, msg, commitMessage);
   }
 
   _populateRevertSubmissionMessage(
@@ -168,13 +159,7 @@
     // Follow the same convention of the revert
     const commitHash = change.current_revision;
     if (!commitHash) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: ERR_COMMIT_NOT_FOUND},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, ERR_COMMIT_NOT_FOUND);
       return;
     }
     if (!changes || changes.length <= 1) return;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.ts
index f5561fc..a4e3e96 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.ts
@@ -100,5 +100,4 @@
       </gr-endpoint-decorator>
     </div>
   </gr-dialog>
-  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
 `;
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..9e1256f 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
@@ -16,23 +16,18 @@
  */
 import '../../shared/gr-dialog/gr-dialog';
 import '../../../styles/shared-styles';
-import '../../shared/gr-js-api-interface/gr-js-api-interface';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-confirm-revert-submission-dialog_html';
 import {customElement, property} from '@polymer/decorators';
-import {JsApiService} from '../../shared/gr-js-api-interface/gr-js-api-types';
 import {ChangeInfo} from '../../../types/common';
+import {fireAlert} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
 
 const ERR_COMMIT_NOT_FOUND = 'Unable to find the commit hash of this change.';
 const CHANGE_SUBJECT_LIMIT = 50;
 
-export interface GrConfirmRevertSubmissionDialog {
-  $: {
-    jsAPI: JsApiService & Element;
-  };
-}
 @customElement('gr-confirm-revert-submission-dialog')
 export class GrConfirmRevertSubmissionDialog extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
@@ -59,6 +54,8 @@
   @property({type: String})
   commitMessage?: string;
 
+  private readonly jsAPI = appContext.jsApiService;
+
   _getTrimmedChangeSubject(subject: string) {
     if (!subject) return '';
     if (subject.length < CHANGE_SUBJECT_LIMIT) return subject;
@@ -69,7 +66,7 @@
     if (!change || !this.message || !this.commitMessage) {
       return this.message;
     }
-    return this.$.jsAPI.modifyRevertSubmissionMsg(
+    return this.jsAPI.modifyRevertSubmissionMsg(
       change,
       this.message,
       this.commitMessage
@@ -86,13 +83,7 @@
     // Follow the same convention of the revert
     const commitHash = change.current_revision;
     if (!commitHash) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: ERR_COMMIT_NOT_FOUND},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, ERR_COMMIT_NOT_FOUND);
       return;
     }
     const revertTitle = `Revert submission ${change.submission_id}`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_html.ts
index cae4e1f..49a9c70 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_html.ts
@@ -57,5 +57,4 @@
       ></iron-autogrow-textarea>
     </div>
   </gr-dialog>
-  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
index 666f95d..df0678ee 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
@@ -17,10 +17,10 @@
 import '@polymer/iron-icon/iron-icon';
 import '../../shared/gr-icons/gr-icons';
 import '../../shared/gr-dialog/gr-dialog';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../../styles/shared-styles';
+import '../gr-thread-list/gr-thread-list';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
@@ -28,6 +28,8 @@
 import {customElement, property} from '@polymer/decorators';
 import {ChangeInfo, ActionInfo} from '../../../types/common';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {pluralize} from '../../../utils/string-util';
+import {CommentThread, isUnresolved} from '../../../utils/comment-util';
 
 export interface GrConfirmSubmitDialog {
   $: {
@@ -60,6 +62,16 @@
   @property({type: Object})
   action?: ActionInfo;
 
+  @property({type: Array})
+  commentThreads?: CommentThread[] = [];
+
+  @property({type: Boolean})
+  _initialised = false;
+
+  init() {
+    this._initialised = true;
+  }
+
   resetFocus() {
     this.$.dialog.resetFocus();
   }
@@ -72,10 +84,15 @@
     );
   }
 
+  _computeUnresolvedThreads(commentThreads?: CommentThread[]) {
+    if (!commentThreads) return [];
+    return commentThreads.filter(thread => isUnresolved(thread));
+  }
+
   _computeUnresolvedCommentsWarning(change: ChangeInfo) {
     const unresolvedCount = change.unresolved_comment_count;
-    const plural = unresolvedCount && unresolvedCount > 1 ? 's' : '';
-    return `Heads Up! ${unresolvedCount} unresolved comment${plural}.`;
+    if (!unresolvedCount) throw new Error('unresolved comments undefined or 0');
+    return `Heads Up! ${pluralize(unresolvedCount, 'unresolved comment')}.`;
   }
 
   _handleConfirmTap(e: MouseEvent) {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts
index 84668ed..29b3752 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts
@@ -25,7 +25,7 @@
       margin-bottom: var(--spacing-l);
     }
     .warningBeforeSubmit {
-      color: var(--error-text-color);
+      color: var(--warning-foreground);
       vertical-align: top;
       margin-right: var(--spacing-s);
     }
@@ -43,43 +43,59 @@
     on-cancel="_handleCancelTap"
     on-confirm="_handleConfirmTap"
   >
-    <div class="header" slot="header">
-      [[action.label]]
-    </div>
-    <div class="main" slot="main">
-      <gr-endpoint-decorator name="confirm-submit-change">
-        <p>Ready to submit “<strong>[[change.subject]]</strong>”?</p>
-        <template is="dom-if" if="[[change.is_private]]">
-          <p>
+    <template is="dom-if" if="[[_initialised]]">
+      <div class="header" slot="header">
+        [[action.label]]
+      </div>
+      <div class="main" slot="main">
+        <gr-endpoint-decorator name="confirm-submit-change">
+          <p>Ready to submit “<strong>[[change.subject]]</strong>”?</p>
+          <template is="dom-if" if="[[change.is_private]]">
+            <p>
+              <iron-icon
+                icon="gr-icons:warning"
+                class="warningBeforeSubmit"
+              ></iron-icon>
+              <strong>Heads Up!</strong>
+              Submitting this private change will also make it public.
+            </p>
+          </template>
+          <template is="dom-if" if="[[change.unresolved_comment_count]]">
+            <p>
+              <iron-icon
+                icon="gr-icons:warning"
+                class="warningBeforeSubmit"
+              ></iron-icon>
+              [[_computeUnresolvedCommentsWarning(change)]]
+            </p>
+            <gr-thread-list
+              id="commentList"
+              threads="[[_computeUnresolvedThreads(commentThreads)]]"
+              change="[[change]]"
+              change-num="[[change._number]]"
+              logged-in="true"
+              hide-toggle-buttons
+            >
+            </gr-thread-list>
+          </template>
+          <template is="dom-if" if="[[_computeHasChangeEdit(change)]]">
             <iron-icon
-              icon="gr-icons:error"
+              icon="gr-icons:warning"
               class="warningBeforeSubmit"
             ></iron-icon>
-            <strong>Heads Up!</strong>
-            Submitting this private change will also make it public.
-          </p>
-        </template>
-        <template is="dom-if" if="[[change.unresolved_comment_count]]">
-          <p>
-            <iron-icon
-              icon="gr-icons:error"
-              class="warningBeforeSubmit"
-            ></iron-icon>
-            [[_computeUnresolvedCommentsWarning(change)]]
-          </p>
-        </template>
-        <template is="dom-if" if="[[_computeHasChangeEdit(change)]]">
-          <iron-icon
-            icon="gr-icons:error"
-            class="warningBeforeSubmit"
-          ></iron-icon>
-          Your unpublished edit will not be submitted. Did you forget to click
-          <b>PUBLISH</b>?
-        </template>
-        <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
-        <gr-endpoint-param name="action" value="[[action]]"></gr-endpoint-param>
-      </gr-endpoint-decorator>
-    </div>
+            Your unpublished edit will not be submitted. Did you forget to click
+            <b>PUBLISH</b>?
+          </template>
+          <gr-endpoint-param
+            name="change"
+            value="[[change]]"
+          ></gr-endpoint-param>
+          <gr-endpoint-param
+            name="action"
+            value="[[action]]"
+          ></gr-endpoint-param>
+        </gr-endpoint-decorator>
+      </div>
+    </template>
   </gr-dialog>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.js
index e16ffdb..e175fda 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.js
@@ -25,6 +25,7 @@
 
   setup(() => {
     element = basicFixture.instantiate();
+    element._initialised = true;
   });
 
   test('display', () => {
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 27d9756..ab1e4e6 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
@@ -20,8 +20,7 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-download-dialog_html';
-import {patchNumEquals} from '../../../utils/patch-set-util';
-import {changeBaseURL} from '../../../utils/change-util';
+import {changeBaseURL, getRevisionKey} from '../../../utils/change-util';
 import {customElement, property, computed, observe} from '@polymer/decorators';
 import {ChangeInfo, ServerInfo, PatchSetNum} from '../../../types/common';
 import {RevisionInfo} from '../../shared/revision-info/revision-info';
@@ -72,7 +71,7 @@
     }
 
     for (const rev of Object.values(this.change.revisions || {})) {
-      if (patchNumEquals(rev._number, this.patchNum)) {
+      if (rev._number === this.patchNum) {
         const fetch = rev.fetch;
         if (fetch) {
           return Object.keys(fetch).sort();
@@ -113,7 +112,7 @@
     if (!change || !selectedScheme) return [];
     for (const rev of Object.values(change.revisions || {})) {
       if (
-        patchNumEquals(rev._number, patchNum) &&
+        rev._number === patchNum &&
         rev &&
         rev.fetch &&
         hasOwnProperty(rev.fetch, selectedScheme)
@@ -123,14 +122,8 @@
       }
     }
     const commands = [];
-    for (const title in commandObj) {
-      if (!commandObj || !hasOwnProperty(commandObj, title)) {
-        continue;
-      }
-      commands.push({
-        title,
-        command: commandObj[title],
-      });
+    for (const [title, command] of Object.entries(commandObj ?? {})) {
+      commands.push({title, command});
     }
     return commands;
   }
@@ -169,13 +162,9 @@
       return '';
     }
 
-    let shortRev = '';
-    for (const rev in change.revisions) {
-      if (patchNumEquals(change.revisions[rev]._number, patchNum)) {
-        shortRev = rev.substr(0, 7);
-        break;
-      }
-    }
+    const rev = getRevisionKey(change, patchNum) ?? '';
+    const shortRev = rev.substr(0, 7);
+
     return shortRev + '.diff.' + (zip ? 'zip' : 'base64');
   }
 
@@ -185,7 +174,7 @@
       return false;
     }
     for (const rev of Object.values(change.revisions || {})) {
-      if (patchNumEquals(rev._number, patchNum)) {
+      if (rev._number === patchNum) {
         const parentLength =
           rev.commit && rev.commit.parents ? rev.commit.parents.length : 0;
         return parentLength === 0 || parentLength > 1;
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.ts
similarity index 65%
rename from polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.js
rename to polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
index 7401026..52ec8af 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.ts
@@ -15,29 +15,46 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-download-dialog.js';
+import '../../../test/common-test-setup-karma';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
+import {
+  createChange,
+  createCommit,
+  createRevision,
+  createRevisions,
+  createServerInfo,
+} from '../../../test/test-data-generators';
+import {
+  CommitId,
+  NumericChangeId,
+  PatchSetNum,
+  RepoName,
+} from '../../../types/common';
+import {GrDownloadDialog} from './gr-download-dialog';
 
 const basicFixture = fixtureFromElement('gr-download-dialog');
 
 function getChangeObject() {
   return {
-    current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72',
+    ...createChange(),
+    current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72' as CommitId,
     revisions: {
       '34685798fe548b6d17d1e8e5edc43a26d055cc72': {
-        _number: 1,
-        commit: {
-          parents: [],
-        },
+        ...createRevision(),
+        commit: createCommit(),
         fetch: {
           repo: {
+            url: 'my.url',
+            ref: 'refs/changes/5/6/1',
             commands: {
               repo: 'repo download test-project 5/1',
             },
           },
           ssh: {
+            url: 'my.url',
+            ref: 'refs/changes/5/6/1',
             commands: {
-              'Checkout':
+              Checkout:
                 'git fetch ' +
                 'ssh://andybons@localhost:29418/test-project ' +
                 'refs/changes/05/5/1 && git checkout FETCH_HEAD',
@@ -50,15 +67,17 @@
                 'ssh://andybons@localhost:29418/test-project ' +
                 'refs/changes/05/5/1 ' +
                 '&& git format-patch -1 --stdout FETCH_HEAD',
-              'Pull':
+              Pull:
                 'git pull ' +
                 'ssh://andybons@localhost:29418/test-project ' +
                 'refs/changes/05/5/1',
             },
           },
           http: {
+            url: 'my.url',
+            ref: 'refs/changes/5/6/1',
             commands: {
-              'Checkout':
+              Checkout:
                 'git fetch ' +
                 'http://andybons@localhost:8080/a/test-project ' +
                 'refs/changes/05/5/1 && git checkout FETCH_HEAD',
@@ -71,7 +90,7 @@
                 'http://andybons@localhost:8080/a/test-project ' +
                 'refs/changes/05/5/1 && ' +
                 'git format-patch -1 --stdout FETCH_HEAD',
-              'Pull':
+              Pull:
                 'git pull ' +
                 'http://andybons@localhost:8080/a/test-project ' +
                 'refs/changes/05/5/1',
@@ -85,41 +104,24 @@
 
 function getChangeObjectNoFetch() {
   return {
-    current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72',
-    revisions: {
-      '34685798fe548b6d17d1e8e5edc43a26d055cc72': {
-        _number: 1,
-        commit: {
-          parents: [],
-        },
-        fetch: {},
-      },
-    },
+    ...createChange(),
+    current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72' as CommitId,
+    revisions: createRevisions(1),
   };
 }
 
 suite('gr-download-dialog', () => {
-  let element;
+  let element: GrDownloadDialog;
 
   setup(() => {
     element = basicFixture.instantiate();
-    element.patchNum = '1';
-    element.config = {
-      schemes: {
-        'anonymous http': {},
-        'http': {},
-        'repo': {},
-        'ssh': {},
-      },
-      archives: ['tgz', 'tar', 'tbz2', 'txz'],
-    };
-
+    element.patchNum = 1 as PatchSetNum;
+    element.config = createServerInfo();
     flush();
   });
 
   test('anchors use download attribute', () => {
-    const anchors = Array.from(
-        element.root.querySelectorAll('a'));
+    const anchors = Array.from(element.root!.querySelectorAll('a'));
     assert.isTrue(!anchors.some(a => !a.hasAttribute('download')));
   });
 
@@ -152,17 +154,28 @@
     });
 
     test('computed fields', () => {
-      assert.equal(element._computeArchiveDownloadLink(
-          {project: 'test/project', _number: 123}, 2, 'tgz'),
-      '/changes/test%2Fproject~123/revisions/2/archive?format=tgz');
+      assert.equal(
+        element._computeArchiveDownloadLink(
+          {
+            ...createChange(),
+            project: 'test/project' as RepoName,
+            _number: 123 as NumericChangeId,
+          },
+          2 as PatchSetNum,
+          'tgz'
+        ),
+        '/changes/test%2Fproject~123/revisions/2/archive?format=tgz'
+      );
     });
 
     test('close event', done => {
       element.addEventListener('close', () => {
         done();
       });
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.closeButtonContainer gr-button'));
+      const closeButton = element.shadowRoot!.querySelector(
+        '.closeButtonContainer gr-button'
+      );
+      tap(closeButton!);
     });
   });
 
@@ -172,35 +185,49 @@
   });
 
   test('_computeHidePatchFile', () => {
-    const patchNum = '1';
+    const patchNum = 1 as PatchSetNum;
 
     const changeWithNoParent = {
+      ...createChange(),
       revisions: {
-        r1: {_number: 1, commit: {parents: []}},
+        r1: {...createRevision(), commit: createCommit()},
       },
     };
     assert.isTrue(element._computeHidePatchFile(changeWithNoParent, patchNum));
 
     const changeWithOneParent = {
+      ...createChange(),
       revisions: {
-        r1: {_number: 1, commit: {parents: [
-          {commit: 'p1'},
-        ]}},
+        r1: {
+          ...createRevision(),
+          commit: {
+            ...createCommit(),
+            parents: [{commit: 'p1' as CommitId, subject: 'subject1'}],
+          },
+        },
       },
     };
     assert.isFalse(
-        element._computeHidePatchFile(changeWithOneParent, patchNum));
+      element._computeHidePatchFile(changeWithOneParent, patchNum)
+    );
 
     const changeWithMultipleParents = {
+      ...createChange(),
       revisions: {
-        r1: {_number: 1, commit: {parents: [
-          {commit: 'p1'},
-          {commit: 'p2'},
-        ]}},
+        r1: {
+          ...createRevision(),
+          commit: {
+            ...createCommit(),
+            parents: [
+              {commit: 'p1' as CommitId, subject: 'subject1'},
+              {commit: 'p2' as CommitId, subject: 'subject2'},
+            ],
+          },
+        },
       },
     };
     assert.isTrue(
-        element._computeHidePatchFile(changeWithMultipleParents, patchNum));
+      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 b86dd90..146b3e2 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
@@ -20,7 +20,6 @@
 import '../../edit/gr-edit-controls/gr-edit-controls';
 import '../../shared/gr-editable-label/gr-editable-label';
 import '../../shared/gr-linked-chip/gr-linked-chip';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-select/gr-select';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-icons/gr-icons';
@@ -36,7 +35,6 @@
 import {
   computeLatestPatchNum,
   getRevisionByPatchNum,
-  patchNumEquals,
   PatchSet,
 } from '../../../utils/patch-set-util';
 import {property, computed, observe, customElement} from '@polymer/decorators';
@@ -46,15 +44,16 @@
   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';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GrButton} from '../../shared/gr-button/gr-button';
+import {appContext} from '../../../services/app-context';
+import {fireEvent} from '../../../utils/event-util';
 
 // Maximum length for patch set descriptions.
 const PATCH_DESC_MAX_LENGTH = 500;
@@ -69,7 +68,6 @@
 export interface GrFileListHeader {
   $: {
     modeSelect: GrDiffModeSelector;
-    restAPI: RestApiService & Element;
     expandBtn: GrButton;
     collapseBtn: GrButton;
   };
@@ -169,6 +167,8 @@
   @property({type: Object})
   revisionInfo?: RevisionInfo;
 
+  private readonly restApiService = appContext.restApiService;
+
   @computed('loggedIn', 'change', 'account')
   get _descriptionReadOnly(): boolean {
     if (
@@ -190,21 +190,11 @@
   }
 
   _expandAllDiffs() {
-    this.dispatchEvent(
-      new CustomEvent('expand-diffs', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'expand-diffs');
   }
 
   _collapseAllDiffs() {
-    this.dispatchEvent(
-      new CustomEvent('collapse-diffs', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'collapse-diffs');
   }
 
   _computeExpandedClass(filesExpanded: FilesExpandedState) {
@@ -294,7 +284,7 @@
       this.patchNum
     )!;
     const sha = this._getPatchsetHash(this.change.revisions, rev);
-    return this.$.restAPI
+    return this.restApiService
       .setDescription(this.changeNum, this.patchNum, desc)
       .then((res: Response) => {
         if (res.ok) {
@@ -330,8 +320,7 @@
   _handlePatchChange(e: CustomEvent) {
     const {basePatchNum, patchNum} = e.detail;
     if (
-      (patchNumEquals(basePatchNum, this.basePatchNum) &&
-        patchNumEquals(patchNum, this.patchNum)) ||
+      (basePatchNum === this.basePatchNum && patchNum === this.patchNum) ||
       !this.change
     ) {
       return;
@@ -341,22 +330,12 @@
 
   _handlePrefsTap(e: Event) {
     e.preventDefault();
-    this.dispatchEvent(
-      new CustomEvent('open-diff-prefs', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'open-diff-prefs');
   }
 
   _handleIncludedInTap(e: Event) {
     e.preventDefault();
-    this.dispatchEvent(
-      new CustomEvent('open-included-in-dialog', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'open-included-in-dialog');
   }
 
   _handleDownloadTap(e: Event) {
@@ -373,7 +352,7 @@
 
   _computePatchInfoClass(patchNum?: PatchSetNum, allPatchSets?: PatchSet[]) {
     const latestNum = computeLatestPatchNum(allPatchSets);
-    if (patchNumEquals(patchNum, latestNum)) {
+    if (patchNum === latestNum) {
       return '';
     }
     return 'patchInfoOldPatchSet';
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
index 1355412..af72b67 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
@@ -29,7 +29,6 @@
     }
     .patchInfo-header {
       align-items: center;
-      border-top: 1px solid var(--border-color);
       display: flex;
       padding: var(--spacing-s) var(--spacing-l);
     }
@@ -48,6 +47,9 @@
     .patchInfoOldPatchSet .container.latestPatchContainer {
       display: initial;
     }
+    .editMode.patchInfoOldPatchSet .container.latestPatchContainer {
+      display: none;
+    }
     .latestPatchContainer a {
       text-decoration: none;
     }
@@ -273,5 +275,4 @@
       </div>
     </div>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
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 3469b3a..f877527 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
@@ -21,6 +21,7 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import 'lodash/lodash.js';
 import {createRevisions} from '../../../test/test-data-generators.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-file-list-header');
 
@@ -28,11 +29,8 @@
   let element;
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({test: 'config'}); },
-      getAccount() { return Promise.resolve(null); },
-      _fetchSharedCacheURL() { return Promise.resolve({}); },
-    });
+    stubRestApi('getConfig').returns(Promise.resolve({test: 'config'}));
+    stubRestApi('getAccount').returns(Promise.resolve(null));
     element = basicFixture.instantiate();
   });
 
@@ -86,7 +84,7 @@
   });
 
   test('description editing', () => {
-    const putDescStub = sinon.stub(element.$.restAPI, 'setDescription')
+    const putDescStub = stubRestApi('setDescription')
         .returns(Promise.resolve({ok: true}));
 
     element.changeNum = '42';
@@ -249,11 +247,11 @@
 
   test('class is applied to file list on old patch set', () => {
     const allPatchSets = [{num: 4}, {num: 2}, {num: 1}];
-    assert.equal(element._computePatchInfoClass('1', allPatchSets),
+    assert.equal(element._computePatchInfoClass(1, allPatchSets),
         'patchInfoOldPatchSet');
-    assert.equal(element._computePatchInfoClass('2', allPatchSets),
+    assert.equal(element._computePatchInfoClass(2, allPatchSets),
         'patchInfoOldPatchSet');
-    assert.equal(element._computePatchInfoClass('4', allPatchSets), '');
+    assert.equal(element._computePatchInfoClass(4, allPatchSets), '');
   });
 
   suite('editMode behavior', () => {
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 e188254..c2947a6 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
@@ -23,10 +23,10 @@
 import '../../shared/gr-cursor-manager/gr-cursor-manager';
 import '../../shared/gr-icons/gr-icons';
 import '../../shared/gr-linked-text/gr-linked-text';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-select/gr-select';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
+import '../../shared/gr-file-status-chip/gr-file-status-chip';
 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';
@@ -39,7 +39,7 @@
   Shortcut,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {FilesExpandedState} from '../gr-file-list-constants';
-import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter';
+import {pluralize} from '../../../utils/string-util';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
@@ -54,10 +54,7 @@
   specialFilePathCompare,
 } from '../../../utils/path-list-util';
 import {customElement, observe, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {
-  ConfigInfo,
-  DiffPreferencesInfo,
   ElementPropertyDeepChange,
   FileInfo,
   FileNameToFileInfoMap,
@@ -67,17 +64,15 @@
   RevisionInfo,
   UrlEncodedCommentId,
 } from '../../../types/common';
+import {DiffPreferencesInfo} from '../../../types/diff';
 import {GrDiffHost} from '../../diff/gr-diff-host/gr-diff-host';
 import {GrDiffPreferencesDialog} from '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {GrDiffCursor} from '../../diff/gr-diff-cursor/gr-diff-cursor';
 import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
 import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
 import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
-import {UIDraft} from '../../../utils/comment-util';
-import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
-import {PatchSetFile} from '../../../types/types';
 import {CustomKeyboardEvent} from '../../../types/events';
+import {ParsedChangeInfo, PatchSetFile} from '../../../types/types';
 
 export const DEFAULT_NUM_FILES_SHOWN = 200;
 
@@ -93,21 +88,10 @@
 const EXPAND_ALL_TIMING_LABEL = 'ExpandAllDiffs';
 const EXPAND_ALL_AVG_TIMING_LABEL = 'ExpandAllPerDiff';
 
-const FileStatus = {
-  A: 'Added',
-  C: 'Copied',
-  D: 'Deleted',
-  M: 'Modified',
-  R: 'Renamed',
-  W: 'Rewritten',
-  U: 'Unchanged',
-};
-
 const FILE_ROW_CLASS = 'file-row';
 
 export interface GrFileList {
   $: {
-    restAPI: RestApiService & Element;
     diffPreferencesDialog: GrDiffPreferencesDialog;
     diffCursor: GrDiffCursor;
     fileCursor: GrCursorManager;
@@ -117,7 +101,7 @@
 interface ReviewedFileInfo extends FileInfo {
   isReviewed?: boolean;
 }
-interface NormalizedFileInfo extends ReviewedFileInfo {
+export interface NormalizedFileInfo extends ReviewedFileInfo {
   __path: string;
 }
 
@@ -168,6 +152,8 @@
 
 export type FileNameToReviewedFileInfoMap = {[name: string]: ReviewedFileInfo};
 
+const DEBOUNCER_LOADING_CHANGE = 'loading-change';
+
 /**
  * Type for FileInfo
  *
@@ -209,15 +195,9 @@
   @property({type: Object})
   changeComments?: ChangeComments;
 
-  @property({type: Object})
-  drafts?: {[path: string]: UIDraft[]};
-
   @property({type: Array})
   revisions?: {[revisionId: string]: RevisionInfo};
 
-  @property({type: Object})
-  projectConfig?: ConfigInfo;
-
   @property({type: Number, notify: true})
   selectedIndex = -1;
 
@@ -334,6 +314,8 @@
 
   private readonly reporting = appContext.reportingService;
 
+  private readonly restApiService = appContext.restApiService;
+
   get keyBindings() {
     return {
       esc: '_handleEscKey',
@@ -426,6 +408,7 @@
   detached() {
     super.detached();
     this._cancelDiffs();
+    this.cancelDebouncer(DEBOUNCER_LOADING_CHANGE);
   }
 
   /**
@@ -457,7 +440,7 @@
     const promises = [];
 
     promises.push(
-      this.$.restAPI
+      this.restApiService
         .getChangeOrEditFiles(changeNum, patchRange)
         .then(filesByPath => {
           this._filesByPath = filesByPath;
@@ -544,11 +527,11 @@
   }
 
   _getDiffPreferences() {
-    return this.$.restAPI.getDiffPreferences();
+    return this.restApiService.getDiffPreferences();
   }
 
   _getPreferences() {
-    return this.$.restAPI.getPreferences();
+    return this.restApiService.getPreferences();
   }
 
   private _toggleFileExpanded(file: PatchSetFile) {
@@ -619,6 +602,21 @@
   _computeCommentsString(
     changeComments?: ChangeComments,
     patchRange?: PatchRange,
+    file?: NormalizedFileInfo
+  ) {
+    if (
+      changeComments === undefined ||
+      patchRange === undefined ||
+      file?.__path === undefined
+    ) {
+      return '';
+    }
+    return changeComments.computeCommentsString(patchRange, file.__path, file);
+  }
+
+  _computeDraftCount(
+    changeComments?: ChangeComments,
+    patchRange?: PatchRange,
     path?: string
   ) {
     if (
@@ -628,39 +626,22 @@
     ) {
       return '';
     }
-    const unresolvedCount =
-      changeComments.computeUnresolvedNum({
-        patchNum: patchRange.basePatchNum,
-        path,
-      }) +
-      changeComments.computeUnresolvedNum({
-        patchNum: patchRange.patchNum,
-        path,
-      });
-    const commentThreadCount =
-      changeComments.computeCommentThreadCount({
-        patchNum: patchRange.basePatchNum,
-        path,
-      }) +
-      changeComments.computeCommentThreadCount({
-        patchNum: patchRange.patchNum,
-        path,
-      });
-    const commentString = GrCountStringFormatter.computePluralString(
-      commentThreadCount,
-      'comment'
-    );
-    const unresolvedString = GrCountStringFormatter.computeString(
-      unresolvedCount,
-      'unresolved'
-    );
-
     return (
-      commentString +
-      // Add a space if both comments and unresolved
-      (commentString && unresolvedString ? ' ' : '') +
-      // Add parentheses around unresolved if it exists.
-      (unresolvedString ? `(${unresolvedString})` : '')
+      changeComments.computeDraftCount({
+        patchNum: patchRange.basePatchNum,
+        path,
+      }) +
+      changeComments.computeDraftCount({
+        patchNum: patchRange.patchNum,
+        path,
+      }) +
+      changeComments.computePortedDraftCount(
+        {
+          patchNum: patchRange.patchNum,
+          basePatchNum: patchRange.basePatchNum,
+        },
+        path
+      )
     );
   }
 
@@ -672,23 +653,13 @@
     patchRange?: PatchRange,
     path?: string
   ) {
-    if (
-      changeComments === undefined ||
-      patchRange === undefined ||
-      path === undefined
-    ) {
-      return '';
-    }
-    const draftCount =
-      changeComments.computeDraftCount({
-        patchNum: patchRange.basePatchNum,
-        path,
-      }) +
-      changeComments.computeDraftCount({
-        patchNum: patchRange.patchNum,
-        path,
-      });
-    return GrCountStringFormatter.computePluralString(draftCount, 'draft');
+    const draftCount = this._computeDraftCount(
+      changeComments,
+      patchRange,
+      path
+    );
+    if (draftCount === '') return draftCount;
+    return pluralize(draftCount, 'draft');
   }
 
   /**
@@ -699,23 +670,12 @@
     patchRange?: PatchRange,
     path?: string
   ) {
-    if (
-      changeComments === undefined ||
-      patchRange === undefined ||
-      path === undefined
-    ) {
-      return '';
-    }
-    const draftCount =
-      changeComments.computeDraftCount({
-        patchNum: patchRange.basePatchNum,
-        path,
-      }) +
-      changeComments.computeDraftCount({
-        patchNum: patchRange.patchNum,
-        path,
-      });
-    return GrCountStringFormatter.computeShortString(draftCount, 'd');
+    const draftCount = this._computeDraftCount(
+      changeComments,
+      patchRange,
+      path
+    );
+    return draftCount === 0 ? '' : `${draftCount}d`;
   }
 
   /**
@@ -742,7 +702,7 @@
         patchNum: patchRange.patchNum,
         path,
       });
-    return GrCountStringFormatter.computeShortString(commentThreadCount, 'c');
+    return commentThreadCount === 0 ? '' : `${commentThreadCount}c`;
   }
 
   private _reviewFile(path: string, reviewed?: boolean) {
@@ -765,7 +725,7 @@
       throw new Error('changeNum and patchRange must be set');
     }
 
-    return this.$.restAPI.saveFileReviewed(
+    return this.restApiService.saveFileReviewed(
       this.changeNum,
       this.patchRange.patchNum,
       path,
@@ -774,14 +734,14 @@
   }
 
   _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
+    return this.restApiService.getLoggedIn();
   }
 
   _getReviewedFiles(changeNum: NumericChangeId, patchRange: PatchRange) {
     if (this.editMode) {
       return Promise.resolve([]);
     }
-    return this.$.restAPI.getReviewedFiles(changeNum, patchRange.patchNum);
+    return this.restApiService.getReviewedFiles(changeNum, patchRange.patchNum);
   }
 
   _normalizeChangeFilesResponse(
@@ -843,7 +803,7 @@
   }
 
   /**
-   * Handle all events from the file list dom-repeat so event handleers don't
+   * Handle all events from the file list dom-repeat so event handlers don't
    * have to get registered for potentially very long lists.
    */
   _handleFileListClick(e: MouseEvent) {
@@ -998,6 +958,7 @@
       return;
     }
     e.preventDefault();
+    this.classList.remove('hideComments');
     this.$.diffCursor.createCommentInPlace();
   }
 
@@ -1110,19 +1071,13 @@
 
   _openCursorFile() {
     const diff = this.$.diffCursor.getTargetDiffElement();
-    if (
-      !this.change ||
-      !diff ||
-      !this.patchRange ||
-      !diff.path ||
-      !diff.patchRange
-    ) {
+    if (!this.change || !diff || !this.patchRange || !diff.path) {
       throw new Error('change, diff and patchRange must be all set and valid');
     }
     GerritNav.navigateToDiff(
       this.change,
       diff.path,
-      diff.patchRange.patchNum,
+      this.patchRange.patchNum,
       this.patchRange.basePatchNum
     );
   }
@@ -1164,12 +1119,6 @@
     );
   }
 
-  _computeFileStatus(
-    status?: keyof typeof FileStatus
-  ): keyof typeof FileStatus {
-    return status || 'M';
-  }
-
   _computeDiffURL(
     change?: ParsedChangeInfo,
     patchRange?: PatchRange,
@@ -1244,12 +1193,6 @@
     return classes.join(' ');
   }
 
-  _computeStatusClass(file?: NormalizedFileInfo) {
-    if (!file) return '';
-    const classStr = this._computeClass('status', file.__path);
-    return `${classStr} ${this._computeFileStatus(file.status)}`;
-  }
-
   _computePathClass(
     path: string | undefined,
     expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
@@ -1300,13 +1243,9 @@
     const files: FileNameToReviewedFileInfoMap = {...filesByPath};
     addUnmodifiedFiles(files, commentedPaths);
     const reviewedSet = new Set(reviewed || []);
-    for (const filePath in files) {
-      if (!hasOwnProperty(files, filePath)) {
-        continue;
-      }
-      files[filePath].isReviewed = reviewedSet.has(filePath);
+    for (const [filePath, reviewedFileInfo] of Object.entries(files)) {
+      reviewedFileInfo.isReviewed = reviewedSet.has(filePath);
     }
-
     this._files = this._normalizeChangeFilesResponse(files);
   }
 
@@ -1405,17 +1344,6 @@
   }
 
   /**
-   * Get a descriptive label for use in the status indicator's tooltip and
-   * ARIA label.
-   */
-  _computeFileStatusLabel(status?: keyof typeof FileStatus) {
-    const statusCode = this._computeFileStatus(status);
-    return hasOwnProperty(FileStatus, statusCode)
-      ? FileStatus[statusCode]
-      : 'Status Unknown';
-  }
-
-  /**
    * Converts any boolean-like variable to the string 'true' or 'false'
    *
    * This method is useful when you bind aria-checked attribute to a boolean
@@ -1557,10 +1485,10 @@
             'changeComments, patchRange and diffPrefs must be set'
           );
         }
-        diffElem.comments = this.changeComments.getCommentsBySideForFile(
+
+        diffElem.threads = this.changeComments.getThreadsBySideForFile(
           file,
-          this.patchRange,
-          this.projectConfig
+          this.patchRange
         );
         const promises: Array<Promise<unknown>> = [diffElem.reload()];
         if (this._loggedIn && !this.diffPrefs.manual_review) {
@@ -1647,14 +1575,9 @@
       return;
     }
 
-    // Comments are not returned with the commentSide attribute from
-    // the api, but it's necessary to be stored on the diff's
-    // comments due to use in the _handleCommentUpdate function.
-    // The comment thread already has a side associated with it, so
-    // set the comment's side to match.
-    threadEl.comments = newComments.map(c =>
-      Object.assign(c, {__commentSide: threadEl.commentSide})
-    );
+    threadEl.comments = newComments.map(c => {
+      return {...c};
+    });
     flush();
   }
 
@@ -1673,7 +1596,7 @@
    */
   _loadingChanged(loading?: boolean) {
     this.debounce(
-      'loading-change',
+      DEBOUNCER_LOADING_CHANGE,
       () => {
         // Only show set the loading if there have been files loaded to show. In
         // this way, the gray loading style is not shown on initial loads.
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
index d93ce68..c57a2d5 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
@@ -80,27 +80,6 @@
       text-align: left;
       width: 1.5em;
     }
-    .status {
-      display: inline-block;
-      border-radius: var(--border-radius);
-      margin-left: var(--spacing-s);
-      padding: 0 var(--spacing-m);
-      color: var(--primary-text-color);
-      font-size: var(--font-size-small);
-      background-color: var(--dark-add-highlight-color);
-    }
-    .status.invisible,
-    .status.M {
-      display: none;
-    }
-    .status.D,
-    .status.R,
-    .status.W {
-      background-color: var(--dark-remove-highlight-color);
-    }
-    .status.U {
-      background-color: var(--comment-background-color);
-    }
     .file-row {
       cursor: pointer;
     }
@@ -175,7 +154,7 @@
       padding-left: var(--spacing-s);
     }
     .drafts {
-      color: #c62828;
+      color: var(--error-foreground);
       font-weight: var(--font-weight-bold);
     }
     .show-hide-icon:focus {
@@ -421,14 +400,7 @@
               >
                 [[_computeTruncatedPath(file.__path)]]
               </span>
-              <span
-                class$="[[_computeStatusClass(file)]]"
-                tabindex="0"
-                title$="[[_computeFileStatusLabel(file.status)]]"
-                aria-label$="[[_computeFileStatusLabel(file.status)]]"
-              >
-                [[_computeFileStatusLabel(file.status)]]
-              </span>
+              <gr-file-status-chip file="[[file]]"></gr-file-status-chip>
               <gr-copy-clipboard
                 hide-input=""
                 text="[[file.__path]]"
@@ -456,8 +428,7 @@
               >
               <span
                 ><!--
-              -->[[_computeCommentsString(changeComments, patchRange,
-                file.__path)]]<!--
+              -->[[_computeCommentsString(changeComments, patchRange, file)]]<!--
            --></span
               >
               <span class="noCommentsScreenReaderText">
@@ -763,8 +734,6 @@
     on-reload-diff-preference="_handleReloadingDiffPreference"
   >
   </gr-diff-preferences-dialog>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  <gr-storage id="storage"></gr-storage>
   <gr-diff-cursor id="diffCursor"></gr-diff-cursor>
   <gr-cursor-manager
     id="fileCursor"
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 d85ae4d..80d8729 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
@@ -26,9 +26,10 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {runA11yAudit} from '../../../test/a11y-test-utils.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {TestKeyboardShortcutBinder} from '../../../test/test-utils.js';
+import {TestKeyboardShortcutBinder, stubRestApi, spyRestApi} from '../../../test/test-utils.js';
 import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api.js';
+import {createCommentThreads} from '../../../utils/comment-util.js';
+import {createChangeComments} from '../../../test/test-data-generators.js';
 
 const commentApiMock = createCommentApiMockWithTemplateElement(
     'gr-file-list-comment-api-mock', html`
@@ -79,15 +80,12 @@
 
   suite('basic tests', () => {
     setup(done => {
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(true); },
-        getPreferences() { return Promise.resolve({}); },
-        getDiffPreferences() { return Promise.resolve({}); },
-        getDiffComments() { return Promise.resolve({}); },
-        getDiffRobotComments() { return Promise.resolve({}); },
-        getDiffDrafts() { return Promise.resolve({}); },
-        getAccountCapabilities() { return Promise.resolve({}); },
-      });
+      stubRestApi('getLoggedIn').returns(Promise.resolve(true));
+      stubRestApi('getPreferences').returns(Promise.resolve({}));
+      stubRestApi('getDiffComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
+      stubRestApi('getAccountCapabilities').returns(Promise.resolve({}));
       stub('gr-date-formatter', {
         _loadTimeFormat() { return Promise.resolve(''); },
       });
@@ -106,8 +104,6 @@
       // been initialized.
       commentApiWrapper.loadComments().then(() => {
         sinon.stub(element.changeComments, 'getPaths').returns({});
-        sinon.stub(element.changeComments, 'getCommentsBySideForPath')
-            .returns({meta: {}, left: [], right: []});
         done();
       });
       element._loading = false;
@@ -310,11 +306,8 @@
         '-1073741824': '-1 GiB',
         '0': '+/-0 B',
       };
-
-      for (const bytes in table) {
-        if (table.hasOwnProperty(bytes)) {
-          assert.equal(element._formatBytes(Number(bytes)), table[bytes]);
-        }
+      for (const [bytes, expected] of Object.entries(table)) {
+        assert.equal(element._formatBytes(Number(bytes)), expected);
       }
     });
 
@@ -353,101 +346,7 @@
     });
 
     test('comment filtering', () => {
-      const comments = {
-        '/COMMIT_MSG': [
-          {
-            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',
-            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: '7',
-            unresolved: true,
-          },
-          {
-            patch_set: 2,
-            message: 'hi',
-            updated: '2017-02-10 16:40:49',
-            id: '8',
-            in_reply_to: '7',
-            unresolved: false,
-          },
-          {
-            patch_set: 2,
-            message: 'good news!',
-            updated: '2017-02-08 16:40:49',
-            id: '9',
-            unresolved: true,
-          },
-        ],
-      };
-      const drafts = {
-        '/COMMIT_MSG': [
-          {
-            patch_set: 1,
-            message: 'hi',
-            updated: '2017-02-15 16:40:49',
-            id: '10',
-            unresolved: true,
-          },
-          {
-            patch_set: 1,
-            message: 'fyi',
-            updated: '2017-02-15 16:40:49',
-            id: '11',
-            unresolved: false,
-          },
-        ],
-        'unresolved.file': [
-          {
-            patch_set: 1,
-            message: 'hi',
-            updated: '2017-02-11 16:40:49',
-            id: '12',
-            unresolved: false,
-          },
-        ],
-      };
-      element.changeComments = new ChangeComments(comments, {}, drafts, 123);
-
+      element.changeComments = createChangeComments();
       const parentTo1 = {
         basePatchNum: 'PARENT',
         patchNum: 1,
@@ -464,12 +363,6 @@
       };
 
       assert.equal(
-          element._computeCommentsString(element.changeComments, parentTo1,
-              '/COMMIT_MSG', 'comment'), '2 comments (1 unresolved)');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, _1To2,
-              '/COMMIT_MSG', 'comment'), '3 comments (1 unresolved)');
-      assert.equal(
           element._computeCommentsStringMobile(element.changeComments, parentTo1
               , '/COMMIT_MSG'), '2c');
       assert.equal(
@@ -488,12 +381,6 @@
           element._computeDraftsStringMobile(element.changeComments, _1To2,
               'unresolved.file'), '1d');
       assert.equal(
-          element._computeCommentsString(element.changeComments, parentTo1,
-              'myfile.txt', 'comment'), '1 comment');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, _1To2,
-              'myfile.txt', 'comment'), '3 comments');
-      assert.equal(
           element._computeCommentsStringMobile(
               element.changeComments,
               parentTo1,
@@ -515,12 +402,6 @@
           element._computeDraftsStringMobile(element.changeComments, _1To2,
               'myfile.txt'), '');
       assert.equal(
-          element._computeCommentsString(element.changeComments, parentTo1,
-              'file_added_in_rev2.txt', 'comment'), '');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, _1To2,
-              'file_added_in_rev2.txt', 'comment'), '');
-      assert.equal(
           element._computeCommentsStringMobile(
               element.changeComments,
               parentTo1,
@@ -542,12 +423,6 @@
           element._computeDraftsStringMobile(element.changeComments, _1To2,
               'file_added_in_rev2.txt'), '');
       assert.equal(
-          element._computeCommentsString(element.changeComments, parentTo2,
-              '/COMMIT_MSG', 'comment'), '1 comment');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, _1To2,
-              '/COMMIT_MSG', 'comment'), '3 comments (1 unresolved)');
-      assert.equal(
           element._computeCommentsStringMobile(
               element.changeComments,
               parentTo2,
@@ -572,12 +447,6 @@
           element._computeDraftsStringMobile(element.changeComments, _1To2,
               '/COMMIT_MSG'), '2d');
       assert.equal(
-          element._computeCommentsString(element.changeComments, parentTo2,
-              'myfile.txt', 'comment'), '2 comments');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, _1To2,
-              'myfile.txt', 'comment'), '3 comments');
-      assert.equal(
           element._computeCommentsStringMobile(
               element.changeComments,
               parentTo2,
@@ -592,18 +461,6 @@
       assert.equal(
           element._computeDraftsStringMobile(element.changeComments, _1To2,
               'myfile.txt'), '');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, parentTo2,
-              'file_added_in_rev2.txt', 'comment'), '');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, _1To2,
-              'file_added_in_rev2.txt', 'comment'), '');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, parentTo2,
-              'unresolved.file', 'comment'), '2 comments (1 unresolved)');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, _1To2,
-              'unresolved.file', 'comment'), '2 comments (1 unresolved)');
     });
 
     test('_reviewedTitle', () => {
@@ -730,12 +587,8 @@
         flush();
         assert.equal(element.diffs.length, paths.length);
         assert.equal(element._expandedFiles.length, paths.length);
-        for (const index in element.diffs) {
-          if (!element.diffs.hasOwnProperty(index)) { continue; }
-          assert.isTrue(
-              element._expandedFiles
-                  .some(f => f.path === element.diffs[index].path)
-          );
+        for (const diff of element.diffs) {
+          assert.isTrue(element._expandedFiles.some(f => f.path === diff.path));
         }
 
         MockInteractions.keyUpOn(element, 73, 'shift', 'i');
@@ -836,16 +689,6 @@
       });
     });
 
-    test('computed properties', () => {
-      assert.equal(element._computeFileStatus('A'), 'A');
-      assert.equal(element._computeFileStatus(undefined), 'M');
-      assert.equal(element._computeFileStatus(null), 'M');
-
-      assert.equal(element._computeClass('clazz', '/foo/bar/baz'), 'clazz');
-      assert.equal(element._computeClass('clazz', '/COMMIT_MSG'),
-          'clazz invisible');
-    });
-
     test('file review status', () => {
       element._reviewed = ['/COMMIT_MSG', 'myfile.txt'];
       element._filesByPath = {
@@ -898,11 +741,6 @@
       assert.isFalse(toggleExpandSpy.called);
     });
 
-    test('_computeFileStatusLabel', () => {
-      assert.equal(element._computeFileStatusLabel('A'), 'Added');
-      assert.equal(element._computeFileStatusLabel('M'), 'Modified');
-    });
-
     test('_handleFileListClick', () => {
       element._filesByPath = {
         '/COMMIT_MSG': {},
@@ -1406,7 +1244,7 @@
       // are no deletions.
       assert.equal(element._computeBarAdditionWidth(file, stats), 30);
 
-      // If there are no insetions, there is no width.
+      // If there are no insertions, there is no width.
       stats.maxInserted = 0;
       assert.equal(element._computeBarAdditionWidth(file, stats), 0);
 
@@ -1498,6 +1336,7 @@
     const commitMsgComments = [
       {
         patch_set: 2,
+        path: '/p',
         id: 'ecf0b9fa_fe1a5f62',
         line: 20,
         updated: '2018-02-08 18:49:18.000000000',
@@ -1506,6 +1345,7 @@
       },
       {
         patch_set: 2,
+        path: '/p',
         id: '503008e2_0ab203ee',
         line: 10,
         updated: '2018-02-14 22:07:43.000000000',
@@ -1514,6 +1354,7 @@
       },
       {
         patch_set: 2,
+        path: '/p',
         id: 'cc788d2c_cb1d728c',
         line: 20,
         in_reply_to: 'ecf0b9fa_fe1a5f62',
@@ -1524,17 +1365,8 @@
     ];
 
     async function setupDiff(diff) {
-      diff.comments = {
-        left: diff.path === '/COMMIT_MSG' ? commitMsgComments : [],
-        right: [],
-        meta: {
-          changeNum: 1,
-          patchRange: {
-            basePatchNum: 'PARENT',
-            patchNum: 2,
-          },
-        },
-      };
+      diff.threads = diff.path === '/COMMIT_MSG' ?
+        createCommentThreads(commitMsgComments) : [];
       diff.prefs = {
         context: 10,
         tab_size: 8,
@@ -1542,18 +1374,16 @@
         line_length: 100,
         cursor_blink_rate: 0,
         line_wrapping: false,
-        intraline_difference: true,
         show_line_endings: true,
         show_tabs: true,
         show_whitespace_errors: true,
         syntax_highlighting: true,
-        auto_hide_diff_table_header: true,
         theme: 'DEFAULT',
         ignore_whitespace: 'IGNORE_NONE',
       };
       diff.diff = getMockDiffResponse();
       commentApiWrapper.loadComments().then(() => {
-        sinon.stub(element.changeComments, 'getCommentsBySideForPath')
+        sinon.stub(element.changeComments, 'getCommentsForPath')
             .withArgs('/COMMIT_MSG', {
               basePatchNum: 'PARENT',
               patchNum: 2,
@@ -1577,13 +1407,10 @@
     }
 
     setup(done => {
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(true); },
-        getPreferences() { return Promise.resolve({}); },
-        getDiffComments() { return Promise.resolve({}); },
-        getDiffRobotComments() { return Promise.resolve({}); },
-        getDiffDrafts() { return Promise.resolve({}); },
-      });
+      stubRestApi('getPreferences').returns(Promise.resolve({}));
+      stubRestApi('getDiffComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
       stub('gr-date-formatter', {
         _loadTimeFormat() { return Promise.resolve(''); },
       });
@@ -1605,8 +1432,6 @@
       // been initialized.
       commentApiWrapper.loadComments().then(() => {
         sinon.stub(element.changeComments, 'getPaths').returns({});
-        sinon.stub(element.changeComments, 'getCommentsBySideForPath')
-            .returns({meta: {}, left: [], right: []});
         done();
       });
       element._loading = false;
@@ -1834,7 +1659,7 @@
       });
 
       test('_getReviewedFiles does not call API', () => {
-        const apiSpy = sinon.spy(element.$.restAPI, 'getReviewedFiles');
+        const apiSpy = spyRestApi('getReviewedFiles');
         element.editMode = true;
         return element._getReviewedFiles().then(files => {
           assert.equal(files.length, 0);
@@ -1888,6 +1713,7 @@
       const commentStubRes1 = [
         {
           patch_set: 2,
+          path: '/p',
           id: '503008e2_0ab203ee',
           line: 20,
           updated: '2018-02-08 18:49:18.000000000',
@@ -1898,6 +1724,7 @@
       const commentStubRes2 = [
         {
           patch_set: 2,
+          path: '/p',
           id: 'ecf0b9fa_fe1a5f62',
           line: 20,
           updated: '2018-02-08 18:49:18.000000000',
@@ -1906,6 +1733,7 @@
         },
         {
           patch_set: 2,
+          path: '/p',
           id: '503008e2_0ab203ee',
           line: 10,
           in_reply_to: 'ecf0b9fa_fe1a5f62',
@@ -1915,6 +1743,7 @@
         },
         {
           patch_set: 2,
+          path: '/p',
           id: '503008e2_0ab203ef',
           line: 20,
           in_reply_to: '503008e2_0ab203ee',
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
index 1957f5c..b50bff4 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
@@ -17,20 +17,13 @@
 import '@polymer/iron-input/iron-input';
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-included-in-dialog_html';
 import {customElement, property} from '@polymer/decorators';
 import {IncludedInInfo, NumericChangeId} from '../../../types/common';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-
-export interface GrIncludedInDialog {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
+import {appContext} from '../../../services/app-context';
 
 interface DisplayGroup {
   title: string;
@@ -63,18 +56,22 @@
   @property({type: String})
   _filterText = '';
 
+  private readonly restApiService = appContext.restApiService;
+
   loadData() {
     if (!this.changeNum) {
       return Promise.reject(new Error('missing required property changeNum'));
     }
     this._filterText = '';
-    return this.$.restAPI.getChangeIncludedIn(this.changeNum).then(configs => {
-      if (!configs) {
-        return;
-      }
-      this._includedIn = configs;
-      this._loaded = true;
-    });
+    return this.restApiService
+      .getChangeIncludedIn(this.changeNum)
+      .then(configs => {
+        if (!configs) {
+          return;
+        }
+        this._includedIn = configs;
+        this._loaded = true;
+      });
   }
 
   _resetData() {
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.ts
index 8e90f3b..2029209 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.ts
@@ -100,5 +100,4 @@
       </ul>
     </div>
   </template>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
index d528192..661cd1a 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-label-score-row/gr-label-score-row';
 import '../../../styles/shared-styles';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
@@ -33,11 +32,13 @@
 } from '../../../types/common';
 import {
   GrLabelScoreRow,
+  Label,
   LabelValuesMap,
 } from '../gr-label-score-row/gr-label-score-row';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {appContext} from '../../../services/app-context';
+import {labelCompare} from '../../../utils/label-util';
 
-type Labels = {[label: string]: number};
 @customElement('gr-label-scores')
 export class GrLabelScores extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
@@ -47,7 +48,7 @@
   }
 
   @property({type: Array, computed: '_computeLabels(change.labels.*, account)'})
-  _labels?: Labels;
+  _labels: Label[] = [];
 
   @property({type: Object, observer: '_computeColumns'})
   permittedLabels?: LabelNameToValueMap;
@@ -61,57 +62,29 @@
   @property({type: Object})
   _labelValues?: LabelValuesMap;
 
+  private readonly reporting = appContext.reportingService;
+
   getLabelValues(includeDefaults = true): LabelNameToValuesMap {
     const labels: LabelNameToValuesMap = {};
     if (this.shadowRoot === null || !this.change) {
       return labels;
     }
-    for (const label in this.permittedLabels) {
-      if (!hasOwnProperty(this.permittedLabels, label)) {
-        continue;
-      }
-
+    for (const label of Object.keys(this.permittedLabels ?? {})) {
       const selectorEl = this.shadowRoot.querySelector(
         `gr-label-score-row[name="${label}"]`
       ) as null | GrLabelScoreRow;
-      if (!selectorEl) {
-        continue;
-      }
-
-      // The user may have not voted on this label.
-      if (!selectorEl.selectedItem) {
-        continue;
-      }
+      if (!selectorEl?.selectedItem) continue;
 
       const selectedVal =
         typeof selectorEl.selectedValue === 'string'
           ? Number(selectorEl.selectedValue)
           : selectorEl.selectedValue;
 
-      if (selectedVal === undefined) {
-        continue;
-      }
-
-      // Only send the selection if the user changed it.
-      const prevVal = this._getVoteForAccount(
-        this.change.labels,
-        label,
-        this.account
-      );
-
-      let prevValNum: number | null | undefined;
-      if (typeof prevVal === 'string') {
-        prevValNum = Number(prevVal);
-      } else {
-        prevValNum = prevVal;
-      }
+      if (selectedVal === undefined) continue;
 
       const defValNum = this._getDefaultValue(this.change.labels, label);
-
-      if (selectedVal !== prevValNum) {
-        if (includeDefaults || !!prevValNum || selectedVal !== defValNum) {
-          labels[label] = selectedVal;
-        }
+      if (includeDefaults || selectedVal !== defValNum) {
+        labels[label] = selectedVal;
       }
     }
     return labels;
@@ -121,13 +94,18 @@
     labels: LabelNameToInfoMap,
     labelName: string,
     numberValue?: number
-  ) {
-    for (const k in (labels[labelName] as DetailedLabelInfo).values) {
-      if (Number(k) === numberValue) {
-        return k;
+  ): string {
+    const detailedInfo = labels[labelName] as DetailedLabelInfo;
+    if (detailedInfo.values) {
+      for (const labelValue of Object.keys(detailedInfo.values)) {
+        if (Number(labelValue) === numberValue) {
+          return labelValue;
+        }
       }
     }
-    return numberValue;
+    const stringVal = `${numberValue}`;
+    this.reporting.reportExecution('label-value-not-found', {value: stringVal});
+    return stringVal;
   }
 
   _getDefaultValue(labels?: LabelNameToInfoMap, labelName?: string) {
@@ -140,7 +118,7 @@
     labels: LabelNameToInfoMap | undefined,
     labelName: string,
     account?: AccountInfo
-  ) {
+  ): string | null {
     if (!labels) return null;
     const votes = labels[labelName] as DetailedLabelInfo;
     if (votes.all && votes.all.length > 0) {
@@ -165,18 +143,12 @@
       LabelNameToInfoMap
     >,
     account?: AccountInfo
-  ) {
-    // Polymer 2: check for undefined
-    if ([labelRecord, account].includes(undefined)) {
-      return undefined;
-    }
-
+  ): Label[] {
+    if (!account) return [];
+    if (!labelRecord?.base) return [];
     const labelsObj = labelRecord.base;
-    if (!labelsObj) {
-      return [];
-    }
     return Object.keys(labelsObj)
-      .sort()
+      .sort(labelCompare)
       .map(key => {
         return {
           name: key,
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js
index ae639e1..ef123c9 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-label-scores.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-label-scores');
 
@@ -24,9 +25,7 @@
   let element;
 
   setup(done => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(false); },
-    });
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
     element = basicFixture.instantiate();
     element.change = {
       _number: '123',
@@ -86,12 +85,10 @@
   });
 
   test('get and set label scores', () => {
-    for (const label in element.permittedLabels) {
-      if (element.permittedLabels.hasOwnProperty(label)) {
-        const row = element.shadowRoot
-            .querySelector('gr-label-score-row[name="' + label + '"]');
-        row.setSelectedValue(-1);
-      }
+    for (const label of Object.keys(element.permittedLabels)) {
+      const row = element.shadowRoot
+          .querySelector('gr-label-score-row[name="' + label + '"]');
+      row.setSelectedValue(-1);
     }
     assert.deepEqual(element.getLabelValues(), {
       'Code-Review': -1,
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
index 8b0cf4b..f913459 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -21,14 +21,13 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../shared/gr-formatted-text/gr-formatted-text';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/shared-styles';
 import '../../../styles/gr-voting-styles';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-message_html';
-import {SpecialFilePath} from '../../../constants/constants';
+import {MessageTag, SpecialFilePath} from '../../../constants/constants';
 import {customElement, property, computed, observe} from '@polymer/decorators';
 import {
   ChangeInfo,
@@ -40,13 +39,25 @@
   VotingRangeInfo,
   NumericChangeId,
   ChangeMessageId,
+  PatchSetNum,
+  AccountInfo,
 } from '../../../types/common';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {CommentThread} from '../../../utils/comment-util';
 import {hasOwnProperty} from '../../../utils/common-util';
+import {appContext} from '../../../services/app-context';
+import {pluralize} from '../../../utils/string-util';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {
+  computeAllPatchSets,
+  computeLatestPatchNum,
+  computePredecessor,
+} from '../../../utils/patch-set-util';
+import {isServiceUser} from '../../../utils/account-util';
 
-const PATCH_SET_PREFIX_PATTERN = /^(?:Uploaded\s*)?(?:P|p)atch (?:S|s)et \d+:\s*(.*)/;
+const PATCH_SET_PREFIX_PATTERN = /^(?:Uploaded\s*)?[Pp]atch [Ss]et \d+:\s*(.*)/;
 const LABEL_TITLE_SCORE_PATTERN = /^(-?)([A-Za-z0-9-]+?)([+-]\d+)?[.]?$/;
+const UPLOADED_NEW_PATCHSET_PATTERN = /Uploaded patch set (\d+)./;
+const MERGED_PATCHSET_PATTERN = /(\d+) is the latest approved patch-set/;
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -58,12 +69,6 @@
   id: ChangeMessageId;
 }
 
-export interface GrMessage {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
-
 interface ChangeMessage extends ChangeMessageInfo {
   // TODO(TS): maybe should be an enum instead
   type: string;
@@ -192,6 +197,8 @@
   })
   _commentCountText = '';
 
+  private readonly restApiService = appContext.restApiService;
+
   created() {
     super.created();
     this.addEventListener('click', e => this._handleClick(e));
@@ -199,13 +206,13 @@
 
   attached() {
     super.attached();
-    this.$.restAPI.getConfig().then(config => {
+    this.restApiService.getConfig().then(config => {
       this.config = config;
     });
-    this.$.restAPI.getLoggedIn().then(loggedIn => {
+    this.restApiService.getLoggedIn().then(loggedIn => {
       this._loggedIn = loggedIn;
     });
-    this.$.restAPI.getIsAdmin().then(isAdmin => {
+    this.restApiService.getIsAdmin().then(isAdmin => {
       this._isAdmin = !!isAdmin;
     });
   }
@@ -220,31 +227,15 @@
   }
 
   _computeCommentCountText(threadsLength?: number) {
-    if (threadsLength === 0) {
+    if (!threadsLength) {
       return undefined;
-    } else if (threadsLength === 1) {
-      return '1 comment';
-    } else {
-      return `${threadsLength} comments`;
     }
-  }
 
-  _onThreadListModified() {
-    // TODO(taoalpha): this won't propagate the changes to the files
-    // should consider replacing this with either top level events
-    // or gerrit level events
-
-    // emit the event so change-view can also get updated with latest changes
-    this.dispatchEvent(
-      new CustomEvent('comment-refresh', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    return pluralize(threadsLength, 'comment');
   }
 
   _computeMessageContentExpanded(content?: string, tag?: ReviewInputTag) {
-    return this._computeMessageContent(content, tag, true);
+    return this._computeMessageContent(true, content, tag);
   }
 
   _patchsetCommentSummary(commentThreads: CommentThread[] = []) {
@@ -276,18 +267,66 @@
     tag?: ReviewInputTag,
     commentThreads?: CommentThread[]
   ) {
-    const summary = this._computeMessageContent(content, tag, false);
+    const summary = this._computeMessageContent(false, content, tag);
     if (summary || !commentThreads) return summary;
     return this._patchsetCommentSummary(commentThreads);
   }
 
+  _showViewDiffButton(message?: ChangeMessage) {
+    return (
+      this._isNewPatchsetTag(message?.tag) || this._isMergePatchset(message)
+    );
+  }
+
+  _isMergePatchset(message?: ChangeMessage) {
+    return (
+      message?.tag === MessageTag.TAG_MERGED &&
+      message?.message.match(MERGED_PATCHSET_PATTERN)
+    );
+  }
+
+  _isNewPatchsetTag(tag?: ReviewInputTag) {
+    return (
+      tag === MessageTag.TAG_NEW_PATCHSET ||
+      tag === MessageTag.TAG_NEW_WIP_PATCHSET
+    );
+  }
+
+  _handleViewPatchsetDiff(e: Event) {
+    if (!this.message || !this.change) return;
+    let patchNum: PatchSetNum;
+    let basePatchNum: PatchSetNum;
+    if (this.message.message.match(UPLOADED_NEW_PATCHSET_PATTERN)) {
+      const match = this.message.message.match(UPLOADED_NEW_PATCHSET_PATTERN)!;
+      if (isNaN(Number(match[1])))
+        throw new Error('invalid patchnum in message');
+      patchNum = Number(match[1]) as PatchSetNum;
+      basePatchNum = computePredecessor(patchNum)!;
+    } else if (this.message.message.match(MERGED_PATCHSET_PATTERN)) {
+      const match = this.message.message.match(MERGED_PATCHSET_PATTERN)!;
+      if (isNaN(Number(match[1])))
+        throw new Error('invalid patchnum in message');
+      basePatchNum = Number(match[1]) as PatchSetNum;
+      patchNum = computeLatestPatchNum(computeAllPatchSets(this.change))!;
+    } else {
+      // Message is of the form "Commit Message was updated" or "Patchset X
+      // was rebased"
+      patchNum = computeLatestPatchNum(computeAllPatchSets(this.change))!;
+      basePatchNum = computePredecessor(patchNum)!;
+    }
+    GerritNav.navigateToChange(this.change, patchNum, basePatchNum);
+    // stop propagation to stop message expansion
+    e.stopPropagation();
+  }
+
   _computeMessageContent(
-    content = '',
-    tag: ReviewInputTag = '' as ReviewInputTag,
-    isExpanded: boolean
+    isExpanded: boolean,
+    content?: string,
+    tag?: ReviewInputTag
   ) {
-    const isNewPatchSet =
-      tag.endsWith(':newPatchSet') || tag.endsWith(':newWipPatchSet');
+    if (!content) return '';
+    const isNewPatchSet = this._isNewPatchsetTag(tag);
+
     const lines = content.split('\n');
     const filteredLines = lines.filter(line => {
       if (!isExpanded && line.startsWith('>')) {
@@ -435,16 +474,17 @@
     return classes.join(' ');
   }
 
-  _computeClass(expanded: boolean) {
+  _computeClass(expanded?: boolean, author?: AccountInfo) {
     const classes = [];
     classes.push(expanded ? 'expanded' : 'collapsed');
+    if (isServiceUser(author)) classes.push('serviceUser');
     return classes.join(' ');
   }
 
   _handleAnchorClick(e: Event) {
     e.preventDefault();
     // The element which triggers _handleAnchorClick is rendered only if
-    // message.id defined: the elemenet is wrapped in dom-if if="[[message.id]]"
+    // message.id defined: the element is wrapped in dom-if if="[[message.id]]"
     const detail: MessageAnchorTapDetail = {
       id: this.message!.id,
     };
@@ -472,7 +512,7 @@
     e.preventDefault();
     if (!this.message || !this.message.id || !this.changeNum) return;
     this._isDeletingChangeMsg = true;
-    this.$.restAPI
+    this.restApiService
       .deleteChangeCommitMessage(this.changeNum, this.message.id)
       .then(() => {
         this._isDeletingChangeMsg = false;
@@ -488,7 +528,7 @@
 
   @observe('projectName')
   _projectNameChanged(name: string) {
-    this.$.restAPI.getProjectConfig(name as RepoName).then(config => {
+    this.restApiService.getProjectConfig(name as RepoName).then(config => {
       this._projectConfig = config;
     });
   }
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
index 96cc959..c018443 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
@@ -39,11 +39,24 @@
     .contentContainer {
       padding: var(--spacing-m) var(--spacing-l);
     }
+    .expanded .contentContainer {
+      background-color: var(--background-color-secondary);
+    }
     .collapsed .contentContainer {
-      /* For expanded state we inherit the alternating background color
-           that is set in gr-messages-list. */
       background-color: var(--background-color-primary);
     }
+    div.serviceUser.expanded div.contentContainer {
+      background-color: var(
+        --background-color-service-user,
+        var(--background-color-secondary)
+      );
+    }
+    div.serviceUser.collapsed div.contentContainer {
+      background-color: var(
+        --background-color-service-user,
+        var(--background-color-primary)
+      );
+    }
     .name {
       font-weight: var(--font-weight-bold);
     }
@@ -96,7 +109,9 @@
       margin-right: var(--spacing-s);
     }
     .authorLabel {
-      width: 140px;
+      min-width: 130px;
+      --account-max-length: 120px;
+      margin-right: var(--spacing-s);
     }
     .expanded .author {
       cursor: pointer;
@@ -111,13 +126,17 @@
       right: var(--spacing-l);
       top: var(--spacing-m);
     }
-    .dateContainer .patchset {
+    .dateContainer gr-button {
       margin-right: var(--spacing-m);
       color: var(--deemphasized-text-color);
     }
     .dateContainer .patchset:before {
       content: 'Patchset ';
     }
+    .dateContainer .patchsetDiffButton {
+      margin-right: var(--spacing-m);
+      --padding: 0 var(--spacing-m);
+    }
     span.date {
       color: var(--deemphasized-text-color);
     }
@@ -182,7 +201,7 @@
       }
     }
   </style>
-  <div class$="[[_computeClass(_expanded)]]">
+  <div class$="[[_computeClass(_expanded, author)]]">
     <div class="contentContainer">
       <div class="author" on-click="_handleAuthorClick">
         <span hidden$="[[!showOnBehalfOf]]">
@@ -253,7 +272,7 @@
               change-num="[[changeNum]]"
               logged-in="[[_loggedIn]]"
               hide-toggle-buttons
-              on-thread-list-modified="_onThreadListModified"
+              show-comment-context
             >
             </gr-thread-list>
           </template>
@@ -276,8 +295,17 @@
         </div>
       </template>
       <span class="dateContainer">
+        <template is="dom-if" if="[[_showViewDiffButton(message)]]">
+          <gr-button
+            class="patchsetDiffButton"
+            on-click="_handleViewPatchsetDiff"
+            link
+          >
+            View Diff
+          </gr-button>
+        </template>
         <template is="dom-if" if="[[message._revision_number]]">
-          <span class="patchset">[[message._revision_number]]</span>
+          <span class="patchset">[[message._revision_number]] |</span>
         </template>
         <template is="dom-if" if="[[!message.id]]">
           <span class="date">
@@ -306,5 +334,4 @@
       </span>
     </div>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js
index a705d45..3f4e13a 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js
@@ -17,6 +17,9 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-message.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {createChange, createRevisions} from '../../../test/test-data-generators.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-message');
 
@@ -25,13 +28,11 @@
 
   suite('when admin and logged in', () => {
     setup(done => {
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(true); },
-        getPreferences() { return Promise.resolve({}); },
-        getConfig() { return Promise.resolve({}); },
-        getIsAdmin() { return Promise.resolve(true); },
-        deleteChangeCommitMessage() { return Promise.resolve({}); },
-      });
+      stubRestApi('getLoggedIn').returns(Promise.resolve(true));
+      stubRestApi('getPreferences').returns(Promise.resolve({}));
+      stubRestApi('getConfig').returns(Promise.resolve({}));
+      stubRestApi('getIsAdmin').returns(Promise.resolve(true));
+      stubRestApi('deleteChangeCommitMessage').returns(Promise.resolve({}));
       element = basicFixture.instantiate();
       flush(done);
     });
@@ -228,20 +229,67 @@
       assert.deepEqual(stub.lastCall.args[0].detail, {id: element.message.id});
     });
 
+    suite('uploaded patchset X message navigates to X - 1 vs  X', () => {
+      let navStub;
+      setup(() => {
+        element.change = {...createChange(), revisions: createRevisions(4)};
+        navStub = sinon.stub(GerritNav, 'navigateToChange');
+      });
+
+      test('Patchset 1 navigates to Base', () => {
+        element.message = {
+          message: 'Uploaded patch set 1.',
+        };
+        element._handleViewPatchsetDiff(new MouseEvent('click'));
+        assert.isTrue(navStub.calledWithExactly(element.change, 1,
+            'PARENT'));
+      });
+
+      test('Patchset X navigates to X vs X - 1', () => {
+        element.message = {
+          message: 'Uploaded patch set 2.',
+        };
+        element._handleViewPatchsetDiff(new MouseEvent('click'));
+        assert.isTrue(navStub.calledWithExactly(element.change, 2, 1));
+
+        element.message = {
+          message: 'Uploaded patch set 200.',
+        };
+        element._handleViewPatchsetDiff(new MouseEvent('click'));
+        assert.isTrue(navStub.calledWithExactly(element.change, 200, 199));
+      });
+
+      test('Commit message updated', () => {
+        element.message = {
+          message: 'Commit message updated.',
+        };
+        element._handleViewPatchsetDiff(new MouseEvent('click'));
+        assert.isTrue(navStub.calledWithExactly(element.change, 4, 3));
+      });
+
+      test('Merged patchset change message', () => {
+        element.message = {
+          message: 'abcd↵3 is the latest approved patch-set.↵abc',
+        };
+        element._handleViewPatchsetDiff(new MouseEvent('click'));
+        assert.isTrue(navStub.calledWithExactly(element.change, 4, 3));
+      });
+    });
+
     suite('compute messages', () => {
       test('empty', () => {
-        assert.equal(element._computeMessageContent('', '', true), '');
-        assert.equal(element._computeMessageContent('', '', false), '');
+        assert.equal(element._computeMessageContent(true, '', ''), '');
+        assert.equal(element._computeMessageContent(false, '', ''), '');
       });
 
       test('new patchset', () => {
         const original = 'Uploaded patch set 1.';
         const tag = 'autogenerated:gerrit:newPatchSet';
-        let actual = element._computeMessageContent(original, tag, true);
+        let actual = element._computeMessageContent(true, original, tag);
         assert.equal(actual, element._computeMessageContentCollapsed(
             original, tag, []));
         assert.equal(actual, original);
-        actual = element._computeMessageContent(original, tag, false);
+        actual = element._computeMessageContent(false, original, tag);
         assert.equal(actual, original);
       });
 
@@ -249,11 +297,11 @@
         const original = 'Patch Set 27: Patch Set 26 was rebased';
         const tag = 'autogenerated:gerrit:newPatchSet';
         const expected = 'Patch Set 26 was rebased';
-        let actual = element._computeMessageContent(original, tag, true);
+        let actual = element._computeMessageContent(true, original, tag);
         assert.equal(actual, expected);
         assert.equal(actual, element._computeMessageContentCollapsed(
             original, tag, []));
-        actual = element._computeMessageContent(original, tag, false);
+        actual = element._computeMessageContent(false, original, tag);
         assert.equal(actual, expected);
       });
 
@@ -261,11 +309,11 @@
         const original = 'Patch Set 1:\n\nThis change is ready for review.';
         const tag = undefined;
         const expected = 'This change is ready for review.';
-        let actual = element._computeMessageContent(original, tag, true);
+        let actual = element._computeMessageContent(true, original, tag);
         assert.equal(actual, expected);
         assert.equal(actual, element._computeMessageContentCollapsed(
             original, tag, []));
-        actual = element._computeMessageContent(original, tag, false);
+        actual = element._computeMessageContent(false, original, tag);
         assert.equal(actual, expected);
       });
 
@@ -273,9 +321,9 @@
         const original = 'Patch Set 1: Code-Style+1';
         const tag = undefined;
         const expected = '';
-        let actual = element._computeMessageContent(original, tag, true);
+        let actual = element._computeMessageContent(true, original, tag);
         assert.equal(actual, expected);
-        actual = element._computeMessageContent(original, tag, false);
+        actual = element._computeMessageContent(false, original, tag);
         assert.equal(actual, expected);
       });
 
@@ -283,9 +331,9 @@
         const original = 'Patch Set 1:\n\n(3 comments)';
         const tag = undefined;
         const expected = '';
-        let actual = element._computeMessageContent(original, tag, true);
+        let actual = element._computeMessageContent(true, original, tag);
         assert.equal(actual, expected);
-        actual = element._computeMessageContent(original, tag, false);
+        actual = element._computeMessageContent(false, original, tag);
         assert.equal(actual, expected);
       });
     });
@@ -374,13 +422,11 @@
 
   suite('when not logged in', () => {
     setup(done => {
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(false); },
-        getPreferences() { return Promise.resolve({}); },
-        getConfig() { return Promise.resolve({}); },
-        getIsAdmin() { return Promise.resolve(false); },
-        deleteChangeCommitMessage() { return Promise.resolve({}); },
-      });
+      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+      stubRestApi('getPreferences').returns(Promise.resolve({}));
+      stubRestApi('getConfig').returns(Promise.resolve({}));
+      stubRestApi('getIsAdmin').returns(Promise.resolve(false));
+      stubRestApi('deleteChangeCommitMessage').returns(Promise.resolve({}));
       element = basicFixture.instantiate();
       flush(done);
     });
@@ -418,7 +464,6 @@
     test('single patchset comment posted', () => {
       const threads = [{
         comments: [{
-          __path: '/PATCHSET_LEVEL',
           change_message_id: '6a07f64a82f96e7337ca5f7f84cfc73abf8ac2a3',
           patch_set: 1,
           id: 'e365b138_bed65caa',
@@ -434,13 +479,12 @@
       }];
       assert.equal(element._computeMessageContentCollapsed(
           '', undefined, threads), 'testing the load');
-      assert.equal(element._computeMessageContent('', undefined, false), '');
+      assert.equal(element._computeMessageContent(false, '', undefined), '');
     });
 
     test('single patchset comment with reply', () => {
       const threads = [{
         comments: [{
-          __path: '/PATCHSET_LEVEL',
           patch_set: 1,
           id: 'e365b138_bed65caa',
           updated: '2020-05-15 13:35:56.000000000',
@@ -449,7 +493,6 @@
           path: '/PATCHSET_LEVEL',
           collapsed: false,
         }, {
-          __path: '/PATCHSET_LEVEL',
           change_message_id: '6a07f64a82f96e7337ca5f7f84cfc73abf8ac2a3',
           patch_set: 1,
           id: 'd6efcc85_4cbbb6f4',
@@ -467,20 +510,18 @@
       }];
       assert.equal(element._computeMessageContentCollapsed(
           '', undefined, threads), 'n');
-      assert.equal(element._computeMessageContent('', undefined, false), '');
+      assert.equal(element._computeMessageContent(false, '', undefined), '');
     });
   });
 
   suite('when logged in but not admin', () => {
-    setup(done => {
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(true); },
-        getConfig() { return Promise.resolve({}); },
-        getIsAdmin() { return Promise.resolve(false); },
-        deleteChangeCommitMessage() { return Promise.resolve({}); },
-      });
+    setup(async () => {
+      stubRestApi('getLoggedIn').returns(Promise.resolve(true));
+      stubRestApi('getConfig').returns(Promise.resolve({}));
+      stubRestApi('getIsAdmin').returns(Promise.resolve(false));
+      stubRestApi('deleteChangeCommitMessage').returns(Promise.resolve({}));
       element = basicFixture.instantiate();
-      flush(done);
+      await flush();
     });
 
     test('can see reply but not delete button', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
index 8557c10..bc3f167 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -48,9 +48,9 @@
 import {CommentThread, isRobot} from '../../../utils/comment-util';
 import {GrMessage, MessageAnchorTapDetail} from '../gr-message/gr-message';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {FormattedReviewerUpdateInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 import {DomRepeat} from '@polymer/polymer/lib/elements/dom-repeat';
 import {getVotingRange} from '../../../utils/label-util';
+import {FormattedReviewerUpdateInfo} from '../../../types/types';
 
 /**
  * The content of the enum is also used in the UI for the button text.
@@ -118,7 +118,7 @@
 
 /**
  * If messages have the same tag, then that influences grouping and whether
- * a message is initally hidden or not, see isImportant(). So we are applying
+ * a message is initially hidden or not, see isImportant(). So we are applying
  * some "magic" rules here in order to hide exactly the right messages.
  *
  * 1. If a message does not have a tag, but is associated with robot comments,
@@ -263,7 +263,7 @@
   _combinedMessages: CombinedMessage[] = [];
 
   @property({type: Object, computed: '_computeLabelExtremes(labels.*)'})
-  _labelExtremes: {[lableName: string]: VotingRangeInfo} = {};
+  _labelExtremes: {[labelName: string]: VotingRangeInfo} = {};
 
   private readonly reporting = appContext.reportingService;
 
@@ -466,7 +466,7 @@
       LabelNameToInfoMap
     >
   ) {
-    const extremes: {[lableName: string]: VotingRangeInfo} = {};
+    const extremes: {[labelName: string]: VotingRangeInfo} = {};
     const labels = labelRecord.base;
     if (!labels) {
       return extremes;
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts
index d3a72072..6c786ef 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts
@@ -24,7 +24,6 @@
     }
     .header {
       align-items: center;
-      border-top: 1px solid var(--border-color);
       border-bottom: 1px solid var(--border-color);
       display: flex;
       justify-content: space-between;
@@ -51,9 +50,6 @@
     gr-message:not(:last-of-type) {
       border-bottom: 1px solid var(--border-color);
     }
-    gr-message {
-      background-color: var(--background-color-secondary);
-    }
   </style>
   <div class="header">
     <div id="showAllActivityToggleContainer" class="container">
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
index c27f9fd..d22c7d0 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
@@ -22,6 +22,7 @@
 import {TEST_ONLY} from './gr-messages-list.js';
 import {MessageTag} from '../../../constants/constants.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 createCommentApiMockWithTemplateElement(
     'gr-messages-list-comment-mock-api', html`
@@ -132,13 +133,11 @@
 
   suite('basic tests', () => {
     setup(() => {
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-        getLoggedIn() { return Promise.resolve(false); },
-        getDiffComments() { return Promise.resolve(comments); },
-        getDiffRobotComments() { return Promise.resolve({}); },
-        getDiffDrafts() { return Promise.resolve({}); },
-      });
+      stubRestApi('getConfig').returns(Promise.resolve({}));
+      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+      stubRestApi('getDiffComments').returns(Promise.resolve(comments));
+      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
 
       messages = generateRandomMessages(3);
       // Element must be wrapped in an element with direct access to the
@@ -441,13 +440,11 @@
     let commentApiWrapper;
 
     setup(() => {
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-        getLoggedIn() { return Promise.resolve(false); },
-        getDiffComments() { return Promise.resolve({}); },
-        getDiffRobotComments() { return Promise.resolve({}); },
-        getDiffDrafts() { return Promise.resolve({}); },
-      });
+      stubRestApi('getConfig').returns(Promise.resolve({}));
+      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+      stubRestApi('getDiffComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
 
       messages = [
         randomMessage(),
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-change.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-change.ts
new file mode 100644
index 0000000..7b698f1
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-change.ts
@@ -0,0 +1,197 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT 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 {GrLitElement} from '../../lit/gr-lit-element';
+import {customElement, property, css} from 'lit-element';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {
+  ChangeInfo,
+  RelatedChangeAndCommitInfo,
+  CommitId,
+} from '../../../types/common';
+import {ChangeStatus} from '../../../constants/constants';
+import {isChangeInfo} from '../../../utils/change-util';
+
+@customElement('gr-related-change')
+export class GrRelatedChange extends GrLitElement {
+  @property()
+  change?: ChangeInfo | RelatedChangeAndCommitInfo;
+
+  @property()
+  href?: string;
+
+  @property()
+  isCurrentChange = false;
+
+  @property()
+  showSubmittableCheck = false;
+
+  @property()
+  showChangeStatus = false;
+
+  /*
+   * Needed for calculation if change is direct or indirect ancestor/descendant
+   * to current change.
+   */
+  @property()
+  connectedRevisions?: CommitId[];
+
+  static get styles() {
+    return [
+      sharedStyles,
+      css`
+        a {
+          display: block;
+        }
+        .changeContainer,
+        a {
+          max-width: 100%;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+        .changeContainer {
+          display: flex;
+        }
+        .strikethrough {
+          color: var(--deemphasized-text-color);
+          text-decoration: line-through;
+        }
+        .status {
+          color: var(--deemphasized-text-color);
+          font-weight: var(--font-weight-bold);
+          margin-left: var(--spacing-xs);
+        }
+        .notCurrent {
+          color: var(--warning-foreground);
+        }
+        .indirectAncestor {
+          color: var(--indirect-ancestor-text-color);
+        }
+        .submittableCheck {
+          padding-left: var(--spacing-s);
+          color: var(--positive-green-text-color);
+          display: none;
+        }
+        .submittableCheck.submittable {
+          display: inline;
+        }
+        .hidden,
+        .mobile {
+          display: none;
+        }
+        .submittableCheck {
+          padding-left: var(--spacing-s);
+          color: var(--positive-green-text-color);
+          display: none;
+        }
+        .submittableCheck.submittable {
+          display: inline;
+        }
+        .arrowToCurrentChange {
+          position: absolute;
+        }
+      `,
+    ];
+  }
+
+  render() {
+    const change = this.change;
+    if (!change) throw new Error('Missing change');
+    const linkClass = this._computeLinkClass(change);
+    return html`<span
+        role="img"
+        class="arrowToCurrentChange"
+        aria-label="Arrow marking current change"
+        ?hidden=${!this.isCurrentChange}
+        >➔</span
+      >
+      <div class="changeContainer">
+        <a href="${this.href}" class="${linkClass}"><slot></slot></a>
+        ${this.showSubmittableCheck
+          ? html`<span
+              tabindex="-1"
+              title="Submittable"
+              class="submittableCheck ${linkClass}"
+              role="img"
+              aria-label="Submittable"
+              >✓</span
+            >`
+          : ''}
+        ${this.showChangeStatus && !isChangeInfo(change)
+          ? html`<span class="${this._computeChangeStatusClass(change)}">
+              (${this._computeChangeStatus(change)})
+            </span>`
+          : ''}
+      </div> `;
+  }
+
+  _computeLinkClass(change: ChangeInfo | RelatedChangeAndCommitInfo) {
+    const statuses = [];
+    if (change.status === ChangeStatus.ABANDONED) {
+      statuses.push('strikethrough');
+    }
+    if (change.submittable) {
+      statuses.push('submittable');
+    }
+    return statuses.join(' ');
+  }
+
+  _computeChangeStatusClass(change: RelatedChangeAndCommitInfo) {
+    const classes = ['status'];
+    if (change._revision_number !== change._current_revision_number) {
+      classes.push('notCurrent');
+    } else if (this._isIndirectAncestor(change)) {
+      classes.push('indirectAncestor');
+    } else if (change.submittable) {
+      classes.push('submittable');
+    } else if (change.status === ChangeStatus.NEW) {
+      classes.push('hidden');
+    }
+    return classes.join(' ');
+  }
+
+  _computeChangeStatus(change: RelatedChangeAndCommitInfo) {
+    switch (change.status) {
+      case ChangeStatus.MERGED:
+        return 'Merged';
+      case ChangeStatus.ABANDONED:
+        return 'Abandoned';
+    }
+    if (change._revision_number !== change._current_revision_number) {
+      return 'Not current';
+    } else if (this._isIndirectAncestor(change)) {
+      return 'Indirect ancestor';
+    } else if (change.submittable) {
+      return 'Submittable';
+    }
+    return '';
+  }
+
+  _isIndirectAncestor(change: RelatedChangeAndCommitInfo) {
+    return (
+      this.connectedRevisions &&
+      !this.connectedRevisions.includes(change.commit.commit)
+    );
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-related-change': GrRelatedChange;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-changes-list-experimental.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-changes-list-experimental.ts
new file mode 100644
index 0000000..214271af
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-changes-list-experimental.ts
@@ -0,0 +1,526 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT 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, nothing} from 'lit-html';
+import './gr-related-change';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../../plugins/gr-endpoint-slot/gr-endpoint-slot';
+import {classMap} from 'lit-html/directives/class-map';
+import {GrLitElement} from '../../lit/gr-lit-element';
+import {customElement, property, css, internalProperty} from 'lit-element';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {
+  SubmittedTogetherInfo,
+  ChangeInfo,
+  RelatedChangeAndCommitInfo,
+  RelatedChangesInfo,
+  PatchSetNum,
+  CommitId,
+} from '../../../types/common';
+import {appContext} from '../../../services/app-context';
+import {ParsedChangeInfo} from '../../../types/types';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {pluralize} from '../../../utils/string-util';
+import {
+  changeIsOpen,
+  getRevisionKey,
+  isChangeInfo,
+} from '../../../utils/change-util';
+
+/** What is the maximum number of shown changes in collapsed list? */
+const MAX_CHANGES_WHEN_COLLAPSED = 3;
+
+@customElement('gr-related-changes-list-experimental')
+export class GrRelatedChangesListExperimental extends GrLitElement {
+  @property()
+  change?: ParsedChangeInfo;
+
+  @property({type: String})
+  patchNum?: PatchSetNum;
+
+  @property()
+  mergeable?: boolean;
+
+  @internalProperty()
+  submittedTogether?: SubmittedTogetherInfo = {
+    changes: [],
+    non_visible_changes: 0,
+  };
+
+  @internalProperty()
+  relatedChanges: RelatedChangeAndCommitInfo[] = [];
+
+  @internalProperty()
+  conflictingChanges: ChangeInfo[] = [];
+
+  @internalProperty()
+  cherryPickChanges: ChangeInfo[] = [];
+
+  @internalProperty()
+  sameTopicChanges: ChangeInfo[] = [];
+
+  private readonly restApiService = appContext.restApiService;
+
+  static get styles() {
+    return [
+      sharedStyles,
+      css`
+        .note {
+          color: var(--error-text-color);
+          margin-left: 1.2em;
+        }
+        section {
+          margin-bottom: var(--spacing-m);
+        }
+      `,
+    ];
+  }
+
+  render() {
+    let showWhenCollapsedPredicate = this.showWhenCollapsedPredicateFactory(
+      this.relatedChanges.length,
+      this.relatedChanges.findIndex(relatedChange =>
+        this._changesEqual(relatedChange, this.change)
+      )
+    );
+    const connectedRevisions = this._computeConnectedRevisions(
+      this.change,
+      this.patchNum,
+      this.relatedChanges
+    );
+    const relatedChangeSection = html` <section
+      class="relatedChanges"
+      ?hidden=${!this.relatedChanges.length}
+    >
+      <gr-related-collapse
+        title="Relation chain"
+        .length=${this.relatedChanges.length}
+      >
+        ${this.relatedChanges.map(
+          (change, index) =>
+            html`<gr-related-change
+              class="${classMap({
+                ['show-when-collapsed']: showWhenCollapsedPredicate(index),
+              })}"
+              .isCurrentChange="${this._changesEqual(change, this.change)}"
+              .change="${change}"
+              .connectedRevisions="${connectedRevisions}"
+              .href="${change?._change_number
+                ? GerritNav.getUrlForChangeById(
+                    change._change_number,
+                    change.project,
+                    change._revision_number as PatchSetNum
+                  )
+                : ''}"
+              .showChangeStatus=${true}
+              >${change.commit.subject}</gr-related-change
+            >`
+        )}
+      </gr-related-collapse>
+    </section>`;
+
+    const submittedTogetherChanges = this.submittedTogether?.changes ?? [];
+    const countNonVisibleChanges =
+      this.submittedTogether?.non_visible_changes ?? 0;
+    showWhenCollapsedPredicate = this.showWhenCollapsedPredicateFactory(
+      submittedTogetherChanges.length,
+      submittedTogetherChanges.findIndex(relatedChange =>
+        this._changesEqual(relatedChange, this.change)
+      )
+    );
+    const submittedTogetherSection = html`<section
+      id="submittedTogether"
+      ?hidden=${!submittedTogetherChanges?.length &&
+      !this.submittedTogether?.non_visible_changes}
+    >
+      <gr-related-collapse
+        title="Submitted together"
+        .length=${submittedTogetherChanges.length}
+      >
+        ${submittedTogetherChanges.map(
+          (change, index) =>
+            html`<gr-related-change
+              class="${classMap({
+                ['show-when-collapsed']: showWhenCollapsedPredicate(index),
+              })}"
+              .isCurrentChange="${this._changesEqual(change, this.change)}"
+              .change="${change}"
+              .href="${GerritNav.getUrlForChangeById(
+                change._number,
+                change.project
+              )}"
+              .showSubmittableCheck=${true}
+              >${change.project}: ${change.branch}:
+              ${change.subject}</gr-related-change
+            >`
+        )}
+      </gr-related-collapse>
+      <div class="note" ?hidden=${!countNonVisibleChanges}>
+        (+ ${pluralize(countNonVisibleChanges, 'non-visible change')})
+      </div>
+    </section>`;
+
+    showWhenCollapsedPredicate = this.showWhenCollapsedPredicateFactory(
+      this.sameTopicChanges.length,
+      -1
+    );
+    const sameTopicSection = html`<section
+      id="sameTopic"
+      ?hidden=${!this.sameTopicChanges?.length}
+    >
+      <gr-related-collapse
+        title="Same topic"
+        .length=${this.sameTopicChanges.length}
+      >
+        ${this.sameTopicChanges.map(
+          (change, index) =>
+            html`<gr-related-change
+              class="${classMap({
+                ['show-when-collapsed']: showWhenCollapsedPredicate(index),
+              })}"
+              .change="${change}"
+              .href="${GerritNav.getUrlForChangeById(
+                change._number,
+                change.project
+              )}"
+              >${change.project}: ${change.branch}:
+              ${change.subject}</gr-related-change
+            >`
+        )}
+      </gr-related-collapse>
+    </section>`;
+
+    showWhenCollapsedPredicate = this.showWhenCollapsedPredicateFactory(
+      this.conflictingChanges.length,
+      -1
+    );
+    const mergeConflictsSection = html`<section
+      id="mergeConflicts"
+      ?hidden=${!this.conflictingChanges?.length}
+    >
+      <gr-related-collapse
+        title="Merge conflicts"
+        .length=${this.conflictingChanges.length}
+      >
+        ${this.conflictingChanges.map(
+          (change, index) =>
+            html`<gr-related-change
+              class="${classMap({
+                ['show-when-collapsed']: showWhenCollapsedPredicate(index),
+              })}"
+              .change="${change}"
+              .href="${GerritNav.getUrlForChangeById(
+                change._number,
+                change.project
+              )}"
+              >${change.subject}</gr-related-change
+            >`
+        )}
+      </gr-related-collapse>
+    </section>`;
+
+    showWhenCollapsedPredicate = this.showWhenCollapsedPredicateFactory(
+      this.cherryPickChanges.length,
+      -1
+    );
+    const cherryPicksSection = html`<section
+      id="cherryPicks"
+      ?hidden=${!this.cherryPickChanges?.length}
+    >
+      <gr-related-collapse
+        title="Cherry picks"
+        .length=${this.cherryPickChanges.length}
+      >
+        ${this.cherryPickChanges.map(
+          (change, index) =>
+            html`<gr-related-change
+              class="${classMap({
+                ['show-when-collapsed']: showWhenCollapsedPredicate(index),
+              })}"
+              .change="${change}"
+              .href="${GerritNav.getUrlForChangeById(
+                change._number,
+                change.project
+              )}"
+              >${change.branch}: ${change.subject}</gr-related-change
+            >`
+        )}
+      </gr-related-collapse>
+    </section>`;
+
+    return html`<gr-endpoint-decorator name="related-changes-section">
+      <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
+      <gr-endpoint-slot name="top"></gr-endpoint-slot>
+      ${relatedChangeSection} ${submittedTogetherSection} ${sameTopicSection}
+      ${mergeConflictsSection} ${cherryPicksSection}
+      <gr-endpoint-slot name="bottom"></gr-endpoint-slot>
+    </gr-endpoint-decorator>`;
+  }
+
+  showWhenCollapsedPredicateFactory(length: number, highlightIndex: number) {
+    return (index: number) => {
+      if (highlightIndex === -1) return index < MAX_CHANGES_WHEN_COLLAPSED;
+      if (highlightIndex === 0) return index <= MAX_CHANGES_WHEN_COLLAPSED - 1;
+      if (highlightIndex === length - 1)
+        return index >= length - MAX_CHANGES_WHEN_COLLAPSED;
+      return (
+        highlightIndex - MAX_CHANGES_WHEN_COLLAPSED + 2 <= index &&
+        index <= highlightIndex + MAX_CHANGES_WHEN_COLLAPSED - 2
+      );
+    };
+  }
+
+  reload(getRelatedChanges?: Promise<RelatedChangesInfo | undefined>) {
+    const change = this.change;
+    if (!change) return Promise.reject(new Error('change missing'));
+    if (!this.patchNum) return Promise.reject(new Error('patchNum missing'));
+    if (!getRelatedChanges) {
+      getRelatedChanges = this.restApiService.getRelatedChanges(
+        change._number,
+        this.patchNum
+      );
+    }
+    const promises: Array<Promise<void>> = [
+      getRelatedChanges.then(response => {
+        if (!response) {
+          throw new Error('getRelatedChanges returned undefined response');
+        }
+        this.relatedChanges = response?.changes ?? [];
+      }),
+      this.restApiService
+        .getChangesSubmittedTogether(change._number)
+        .then(response => {
+          this.submittedTogether = response;
+        }),
+      this.restApiService
+        .getChangeCherryPicks(change.project, change.change_id, change._number)
+        .then(response => {
+          this.cherryPickChanges = response || [];
+        }),
+    ];
+
+    // Get conflicts if change is open and is mergeable.
+    // Mergeable is output of restApiServict.getMergeable from gr-change-view
+    if (changeIsOpen(change) && this.mergeable) {
+      promises.push(
+        this.restApiService
+          .getChangeConflicts(change._number)
+          .then(response => {
+            this.conflictingChanges = response ?? [];
+          })
+      );
+    }
+    if (change.topic) {
+      const changeTopic = change.topic;
+      promises.push(
+        this.restApiService.getConfig().then(config => {
+          if (config && !config.change.submit_whole_topic) {
+            return this.restApiService
+              .getChangesWithSameTopic(changeTopic, change._number)
+              .then(response => {
+                if (changeTopic === this.change?.topic) {
+                  this.sameTopicChanges = response ?? [];
+                }
+              });
+          }
+          this.sameTopicChanges = [];
+          return Promise.resolve();
+        })
+      );
+    }
+
+    return Promise.all(promises);
+  }
+
+  /**
+   * Do the given objects describe the same change? Compares the changes by
+   * their numbers.
+   */
+  _changesEqual(
+    a?: ChangeInfo | RelatedChangeAndCommitInfo,
+    b?: ChangeInfo | ParsedChangeInfo | RelatedChangeAndCommitInfo
+  ) {
+    const aNum = this._getChangeNumber(a);
+    const bNum = this._getChangeNumber(b);
+    return aNum === bNum;
+  }
+
+  /**
+   * Get the change number from either a ChangeInfo (such as those included in
+   * SubmittedTogetherInfo responses) or get the change number from a
+   * RelatedChangeAndCommitInfo (such as those included in a
+   * RelatedChangesInfo response).
+   */
+  _getChangeNumber(
+    change?: ChangeInfo | ParsedChangeInfo | RelatedChangeAndCommitInfo
+  ) {
+    // Default to 0 if change property is not defined.
+    if (!change) return 0;
+
+    if (isChangeInfo(change)) {
+      return change._number;
+    }
+    return change._change_number;
+  }
+
+  /*
+   * A list of commit ids connected to change to understand if other change
+   * is direct or indirect ancestor / descendant.
+   */
+  _computeConnectedRevisions(
+    change?: ParsedChangeInfo,
+    patchNum?: PatchSetNum,
+    relatedChanges?: RelatedChangeAndCommitInfo[]
+  ) {
+    if (!patchNum || !relatedChanges || !change) {
+      return [];
+    }
+
+    const connected: CommitId[] = [];
+    const changeRevision = getRevisionKey(change, patchNum);
+    const commits = relatedChanges.map(c => c.commit);
+    let pos = commits.length - 1;
+
+    while (pos >= 0) {
+      const commit: CommitId = commits[pos].commit;
+      connected.push(commit);
+      // TODO(TS): Ensure that both (commit and changeRevision) are string and use === instead
+      // eslint-disable-next-line eqeqeq
+      if (commit == changeRevision) {
+        break;
+      }
+      pos--;
+    }
+    while (pos >= 0) {
+      for (let i = 0; i < commits[pos].parents.length; i++) {
+        if (connected.includes(commits[pos].parents[i].commit)) {
+          connected.push(commits[pos].commit);
+          break;
+        }
+      }
+      --pos;
+    }
+    return connected;
+  }
+}
+
+@customElement('gr-related-collapse')
+export class GrRelatedCollapse extends GrLitElement {
+  @property()
+  title = '';
+
+  @property()
+  showAll = false;
+
+  @property()
+  length = 0;
+
+  private readonly reporting = appContext.reportingService;
+
+  static get styles() {
+    return [
+      sharedStyles,
+      css`
+        .title {
+          font-weight: var(--font-weight-bold);
+          color: var(--deemphasized-text-color);
+          padding-left: var(--metadata-horizontal-padding);
+        }
+        h4 {
+          display: flex;
+          align-self: flex-end;
+        }
+        gr-button {
+          display: flex;
+        }
+        /* This is a hacky solution from old gr-related-change-list
+         * TODO(milutin): find layout without needing it
+         */
+        h4:before,
+        gr-button:before,
+        ::slotted(gr-related-change):before {
+          content: ' ';
+          flex-shrink: 0;
+          width: 1.2em;
+        }
+        .collapsed ::slotted(gr-related-change.show-when-collapsed) {
+          display: flex;
+        }
+        .collapsed ::slotted(gr-related-change) {
+          display: none;
+        }
+        ::slotted(gr-related-change) {
+          display: flex;
+        }
+        gr-button iron-icon {
+          color: inherit;
+          --iron-icon-height: 18px;
+          --iron-icon-width: 18px;
+        }
+        .container {
+          justify-content: space-between;
+          display: flex;
+          margin-bottom: var(--spacing-s);
+        }
+      `,
+    ];
+  }
+
+  render() {
+    const title = html`<h4 class="title">${this.title}</h4>`;
+
+    const collapsible = this.length > MAX_CHANGES_WHEN_COLLAPSED;
+    const items = html` <div
+      class="${!this.showAll && collapsible ? 'collapsed' : ''}"
+    >
+      <slot></slot>
+    </div>`;
+
+    let button = nothing;
+    if (collapsible) {
+      if (this.showAll) {
+        button = html`<gr-button link="" @click="${this.toggle}"
+          >Show less<iron-icon icon="gr-icons:expand-less"></iron-icon
+        ></gr-button>`;
+      } else {
+        button = html`<gr-button link="" @click="${this.toggle}"
+          >Show all (${this.length})
+          <iron-icon icon="gr-icons:expand-more"></iron-icon
+        ></gr-button>`;
+      }
+    }
+
+    return html`<div class="container">${title}${button}</div>
+      ${items}`;
+  }
+
+  private toggle(e: MouseEvent) {
+    e.stopPropagation();
+    this.showAll = !this.showAll;
+    this.reporting.reportInteraction('toggle show all button', {
+      sectionName: this.title,
+      toState: this.showAll ? 'Show all' : 'Show less',
+    });
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-related-changes-list-experimental': GrRelatedChangesListExperimental;
+    'gr-related-collapse': GrRelatedCollapse;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
index f8a4af2..5705c4f 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/shared-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
@@ -26,11 +25,14 @@
 import {htmlTemplate} from './gr-related-changes-list_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {ChangeStatus} from '../../../constants/constants';
-import {patchNumEquals} from '../../../utils/patch-set-util';
-import {changeIsOpen} from '../../../utils/change-util';
+
+import {
+  changeIsOpen,
+  getRevisionKey,
+  isChangeInfo,
+} from '../../../utils/change-util';
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {customElement, observe, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {
   ChangeId,
   ChangeInfo,
@@ -42,24 +44,14 @@
   RepoName,
   SubmittedTogetherInfo,
 } from '../../../types/common';
-import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
-
-export interface GrRelatedChangesList {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
+import {appContext} from '../../../services/app-context';
+import {pluralize} from '../../../utils/string-util';
+import {ParsedChangeInfo} from '../../../types/types';
 
 function getEmptySubmitTogetherInfo(): SubmittedTogetherInfo {
   return {changes: [], non_visible_changes: 0};
 }
 
-function isChangeInfo(
-  x: ChangeInfo | RelatedChangeAndCommitInfo
-): x is ChangeInfo {
-  return (x as ChangeInfo)._number !== undefined;
-}
-
 @customElement('gr-related-changes-list')
 export class GrRelatedChangesList extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
@@ -117,6 +109,10 @@
   @property({type: Array})
   _sameTopic?: ChangeInfo[] = [];
 
+  private readonly restApiService = appContext.restApiService;
+
+  private readonly reportingService = appContext.reportingService;
+
   clear() {
     this.loading = true;
     this.hidden = true;
@@ -135,7 +131,7 @@
     const change = this.change;
     this.loading = true;
     const promises: Array<Promise<void>> = [
-      this.$.restAPI
+      this.restApiService
         .getRelatedChanges(change._number, this.patchNum)
         .then(response => {
           if (!response) {
@@ -148,13 +144,13 @@
             response.changes
           );
         }),
-      this.$.restAPI
+      this.restApiService
         .getChangesSubmittedTogether(change._number)
         .then(response => {
           this._submittedTogether = response;
           this._fireReloadEvent();
         }),
-      this.$.restAPI
+      this.restApiService
         .getChangeCherryPicks(change.project, change.change_id, change._number)
         .then(response => {
           this._cherryPicks = response || [];
@@ -165,12 +161,14 @@
     // Get conflicts if change is open and is mergeable.
     if (changeIsOpen(change) && this.mergeable) {
       promises.push(
-        this.$.restAPI.getChangeConflicts(change._number).then(response => {
-          // Because the server doesn't always return a response and the
-          // template expects an array, always return an array.
-          this._conflicts = response ? response : [];
-          this._fireReloadEvent();
-        })
+        this.restApiService
+          .getChangeConflicts(change._number)
+          .then(response => {
+            // Because the server doesn't always return a response and the
+            // template expects an array, always return an array.
+            this._conflicts = response ? response : [];
+            this._fireReloadEvent();
+          })
       );
     }
 
@@ -181,7 +179,7 @@
             throw new Error('_getServerConfig returned undefined ');
           }
           if (!config.change.submit_whole_topic) {
-            return this.$.restAPI
+            return this.restApiService
               .getChangesWithSameTopic(change.topic, change._number)
               .then(response => {
                 this._sameTopic = response;
@@ -222,7 +220,7 @@
   }
 
   _getServerConfig() {
-    return this.$.restAPI.getConfig();
+    return this.restApiService.getConfig();
   }
 
   _computeChangeURL(
@@ -397,25 +395,12 @@
     patchNum?: PatchSetNum,
     relatedChanges?: RelatedChangeAndCommitInfo[]
   ) {
-    // Polymer 2: check for undefined
-    if (
-      change === undefined ||
-      patchNum === undefined ||
-      relatedChanges === undefined
-    ) {
-      return undefined;
+    if (!patchNum || !relatedChanges || !change) {
+      return [];
     }
 
     const connected: CommitId[] = [];
-    let changeRevision;
-    if (!change) {
-      return [];
-    }
-    for (const rev in change.revisions) {
-      if (patchNumEquals(change.revisions[rev]._number, patchNum)) {
-        changeRevision = rev;
-      }
-    }
+    const changeRevision = getRevisionKey(change, patchNum);
     const commits = relatedChanges.map(c => c.commit);
     let pos = commits.length - 1;
 
@@ -453,8 +438,28 @@
   }
 
   _computeNonVisibleChangesNote(n: number) {
-    const noun = n === 1 ? 'change' : 'changes';
-    return `(+ ${n} non-visible ${noun})`;
+    return `(+ ${pluralize(n, 'non-visible change')})`;
+  }
+
+  // TODO(milutin): Temporary for data collection, remove when data collected
+  _reportClick(e: Event) {
+    const target = e.target as HTMLAnchorElement | undefined;
+    const section = target?.parentElement?.parentElement;
+    const sectionName = section?.getElementsByTagName('h4')[0]?.innerText;
+    const sectionLinks = [...(section?.getElementsByTagName('a') ?? [])];
+    const currentChange = section
+      ?.getElementsByClassName('arrowToCurrentChange')[0]
+      ?.nextElementSibling?.nextElementSibling?.getElementsByTagName('a')[0];
+
+    if (!target) return;
+    this.reportingService.reportInteraction('related-change-click', {
+      sectionName,
+      index: sectionLinks.indexOf(target) + 1,
+      countChanges: sectionLinks.length,
+      currentChangeIndex: !currentChange
+        ? undefined
+        : sectionLinks.indexOf(currentChange) + 1,
+    });
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.ts
index d4cd0f6..2f53319 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.ts
@@ -66,10 +66,10 @@
       margin-left: var(--spacing-xs);
     }
     .notCurrent {
-      color: #e65100;
+      color: var(--warning-foreground);
     }
     .indirectAncestor {
-      color: #33691e;
+      color: var(--indirect-ancestor-text-color);
     }
     .submittableCheck {
       padding-left: var(--spacing-s);
@@ -117,6 +117,7 @@
               href$="[[_computeChangeURL(related._change_number, related.project, related._revision_number)]]"
               class$="[[_computeLinkClass(related)]]"
               title$="[[related.commit.subject]]"
+              on-click="_reportClick"
             >
               [[related.commit.subject]]
             </a>
@@ -149,6 +150,7 @@
               href$="[[_computeChangeURL(related._number, related.project)]]"
               class$="[[_computeLinkClass(related)]]"
               title$="[[related.project]]: [[related.branch]]: [[related.subject]]"
+              on-click="_reportClick"
             >
               [[related.project]]: [[related.branch]]: [[related.subject]]
             </a>
@@ -176,6 +178,7 @@
               href$="[[_computeChangeURL(change._number, change.project)]]"
               class$="[[_computeLinkClass(change)]]"
               title$="[[change.project]]: [[change.branch]]: [[change.subject]]"
+              on-click="_reportClick"
             >
               [[change.project]]: [[change.branch]]: [[change.subject]]
             </a>
@@ -190,6 +193,7 @@
               href$="[[_computeChangeURL(change._number, change.project)]]"
               class$="[[_computeLinkClass(change)]]"
               title$="[[change.subject]]"
+              on-click="_reportClick"
             >
               [[change.subject]]
             </a>
@@ -204,6 +208,7 @@
               href$="[[_computeChangeURL(change._number, change.project)]]"
               class$="[[_computeLinkClass(change)]]"
               title$="[[change.branch]]: [[change.subject]]"
+              on-click="_reportClick"
             >
               [[change.branch]]: [[change.subject]]
             </a>
@@ -214,5 +219,4 @@
     </gr-endpoint-decorator>
   </div>
   <div hidden$="[[!loading]]">Loading...</div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.js b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.js
index 3983c5a..f54b819 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.js
@@ -21,6 +21,7 @@
 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 {resetPlugins} from '../../../test/test-utils.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const pluginApi = _testOnly_initGerritPluginApi();
 
@@ -237,14 +238,10 @@
     };
     element.mergeable = true;
     element.addEventListener('new-section-loaded', loadedStub);
-    sinon.stub(element.$.restAPI, 'getRelatedChanges')
-        .returns(Promise.resolve({changes: []}));
-    sinon.stub(element.$.restAPI, 'getChangesSubmittedTogether')
-        .returns(Promise.resolve());
-    sinon.stub(element.$.restAPI, 'getChangeCherryPicks')
-        .returns(Promise.resolve());
-    sinon.stub(element.$.restAPI, 'getChangeConflicts')
-        .returns(Promise.resolve());
+    stubRestApi('getRelatedChanges').returns(Promise.resolve({changes: []}));
+    stubRestApi('getChangesSubmittedTogether').returns(Promise.resolve());
+    stubRestApi('getChangeCherryPicks').returns(Promise.resolve());
+    stubRestApi('getChangeConflicts').returns(Promise.resolve());
 
     return element.reload().then(() => {
       assert.equal(loadedStub.callCount, 4);
@@ -257,14 +254,10 @@
     setup(() => {
       element = basicFixture.instantiate();
 
-      sinon.stub(element.$.restAPI, 'getRelatedChanges')
-          .returns(Promise.resolve({changes: []}));
-      sinon.stub(element.$.restAPI, 'getChangesSubmittedTogether')
-          .returns(Promise.resolve());
-      sinon.stub(element.$.restAPI, 'getChangeCherryPicks')
-          .returns(Promise.resolve());
-      sinon.stub(element.$.restAPI, 'getChangeConflicts')
-          .returns(Promise.resolve());
+      stubRestApi('getRelatedChanges').returns(Promise.resolve({changes: []}));
+      stubRestApi('getChangesSubmittedTogether').returns(Promise.resolve());
+      stubRestApi('getChangeCherryPicks').returns(Promise.resolve());
+      stubRestApi('getChangeConflicts').returns(Promise.resolve());
     });
 
     test('_conflicts are an empty array', () => {
@@ -286,14 +279,11 @@
     setup(() => {
       element = basicFixture.instantiate();
 
-      sinon.stub(element.$.restAPI, 'getRelatedChanges')
-          .returns(Promise.resolve({changes: []}));
-      sinon.stub(element.$.restAPI, 'getChangesSubmittedTogether')
-          .returns(Promise.resolve());
-      sinon.stub(element.$.restAPI, 'getChangeCherryPicks')
-          .returns(Promise.resolve());
-      conflictsStub = sinon.stub(element.$.restAPI, 'getChangeConflicts')
-          .returns(Promise.resolve());
+      stubRestApi('getRelatedChanges').returns(Promise.resolve({changes: []}));
+      stubRestApi('getChangesSubmittedTogether').returns(Promise.resolve());
+      stubRestApi('getChangeCherryPicks').returns(Promise.resolve());
+      conflictsStub = stubRestApi('getChangeConflicts').returns(
+          Promise.resolve());
     });
 
     test('request conflicts if open and mergeable', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
index 8a4b1f6..b09510ba 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
@@ -21,6 +21,8 @@
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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 {stubRestApi} from '../../../test/test-utils.js';
+
 const basicFixture = fixtureFromElement('gr-reply-dialog');
 const pluginApi = _testOnly_initGerritPluginApi();
 
@@ -73,10 +75,8 @@
     changeNum = 42;
     patchNum = 1;
 
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-      getAccount() { return Promise.resolve({_account_id: 42}); },
-    });
+    stubRestApi('getConfig').returns(Promise.resolve({}));
+    stubRestApi('getAccount').returns(Promise.resolve({_account_id: 42}));
 
     element = basicFixture.instantiate();
     setupElement(element);
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 7b34263..a8e89b6 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
@@ -20,9 +20,7 @@
 import '../../shared/gr-textarea/gr-textarea';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-formatted-text/gr-formatted-text';
-import '../../shared/gr-js-api-interface/gr-js-api-interface';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-storage/gr-storage';
 import '../../shared/gr-account-list/gr-account-list';
 import '../gr-label-scores/gr-label-scores';
@@ -43,18 +41,13 @@
   ReviewerState,
   SpecialFilePath,
 } from '../../../constants/constants';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {fetchChangeUpdates} from '../../../utils/patch-set-util';
 import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {accountKey, removeServiceUsers} from '../../../utils/account-util';
 import {getDisplayName} from '../../../utils/display-name-util';
 import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
-import {TargetElement} from '../../plugins/gr-plugin-types';
+import {TargetElement} from '../../../api/plugin';
 import {customElement, observe, property} from '@polymer/decorators';
-import {
-  ErrorCallback,
-  RestApiService,
-} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {FixIronA11yAnnouncer} from '../../../types/types';
 import {
   AccountAddition,
@@ -64,7 +57,6 @@
   GroupObjectInput,
   RawAccountInput,
 } from '../../shared/gr-account-list/gr-account-list';
-import {JsApiService} from '../../shared/gr-js-api-interface/gr-js-api-types';
 import {
   AccountId,
   AccountInfo,
@@ -75,6 +67,7 @@
   GroupId,
   GroupInfo,
   isAccount,
+  isDetailedLabelInfo,
   isGroup,
   isReviewerAccountSuggestion,
   isReviewerGroupSuggestion,
@@ -99,6 +92,7 @@
 } from '@polymer/polymer/interfaces';
 import {
   areSetsEqual,
+  assertIsDefined,
   assertNever,
   containsAll,
 } from '../../../utils/common-util';
@@ -108,8 +102,15 @@
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrStorage, StorageLocation} from '../../shared/gr-storage/gr-storage';
 import {isAttentionSetEnabled} from '../../../utils/attention-set-util';
-import {CODE_REVIEW, getMaxAccounts} from '../../../utils/label-util';
+import {
+  CODE_REVIEW,
+  getApprovalInfo,
+  getMaxAccounts,
+} from '../../../utils/label-util';
 import {isUnresolved} from '../../../utils/comment-util';
+import {pluralize} from '../../../utils/string-util';
+import {fireAlert, fireEvent, fireServerError} from '../../../utils/event-util';
+import {ErrorCallback} from '../../../api/rest';
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
@@ -137,9 +138,10 @@
 };
 
 const ButtonTooltips = {
-  SAVE: 'Save but do not send notification or change review state',
+  SAVE: 'Send changes and comments as work in progress but do not start review',
   START_REVIEW: 'Mark as ready for review and send reply',
   SEND: 'Send reply',
+  DISABLED_COMMENT_EDITING: 'Save draft comments to enable send',
 };
 
 const EMPTY_REPLY_MESSAGE = 'Cannot send an empty reply.';
@@ -157,8 +159,6 @@
 
 export interface GrReplyDialog {
   $: {
-    restAPI: RestApiService & Element;
-    jsAPI: JsApiService & Element;
     reviewers: GrAccountList;
     ccs: GrAccountList;
     cancelButton: GrButton;
@@ -166,10 +166,11 @@
     labelScores: GrLabelScores;
     textarea: GrTextarea;
     reviewerConfirmationOverlay: GrOverlay;
-    storage: GrStorage;
   };
 }
 
+const DEBOUNCER_STORE = 'store';
+
 @customElement('gr-reply-dialog')
 export class GrReplyDialog extends KeyboardShortcutMixin(
   GestureEventListeners(LegacyElementMixin(PolymerElement))
@@ -358,8 +359,7 @@
     computed:
       '_computeSendButtonDisabled(canBeStarted, ' +
       'draftCommentThreads, draft, _reviewersMutated, _labelsChanged, ' +
-      '_includeComments, disabled, _commentEditing, _attentionExpanded, ' +
-      '_currentAttentionSet, _newAttentionSet)',
+      '_includeComments, disabled, _commentEditing, change, _account)',
     observer: '_sendDisabledChanged',
   })
   _sendDisabled?: boolean;
@@ -373,6 +373,12 @@
   @property({type: Array, computed: '_computeAllReviewers(_reviewers.*)'})
   _allReviewers: (AccountInfo | GroupInfo)[] = [];
 
+  private readonly restApiService = appContext.restApiService;
+
+  private readonly storage = new GrStorage();
+
+  private readonly jsAPI = appContext.jsApiService;
+
   get keyBindings() {
     return {
       esc: '_handleEscKey',
@@ -380,8 +386,6 @@
     };
   }
 
-  _isPatchsetCommentsExperimentEnabled = false;
-
   constructor() {
     super();
     this.filterReviewerSuggestion = this._filterReviewerSuggestionGenerator(
@@ -421,16 +425,18 @@
   /** @override */
   ready() {
     super.ready();
-    this._isPatchsetCommentsExperimentEnabled = this.flagsService.isEnabled(
-      KnownExperimentId.PATCHSET_COMMENTS
-    );
-    this.$.jsAPI.addElement(TargetElement.REPLY_DIALOG, this);
+    this.jsAPI.addElement(TargetElement.REPLY_DIALOG, this);
+  }
+
+  /** @override */
+  detached() {
+    this.cancelDebouncer(DEBOUNCER_STORE);
   }
 
   open(focusTarget?: FocusTarget) {
-    if (!this.change) throw new Error('missing required change property');
+    assertIsDefined(this.change, 'change');
     this.knownLatestState = LatestPatchState.CHECKING;
-    fetchChangeUpdates(this.change, this.$.restAPI).then(result => {
+    fetchChangeUpdates(this.change, this.restApiService).then(result => {
       this.knownLatestState = result.isLatest
         ? LatestPatchState.LATEST
         : LatestPatchState.NOT_LATEST;
@@ -445,15 +451,10 @@
       // Otherwise, check for an unsaved draft in localstorage.
       this.draft = this._loadStoredDraft();
     }
-    if (this.$.restAPI.hasPendingDiffDrafts()) {
+    if (this.restApiService.hasPendingDiffDrafts()) {
       this._savingComments = true;
-      this.$.restAPI.awaitPendingDiffDrafts().then(() => {
-        this.dispatchEvent(
-          new CustomEvent('comment-refresh', {
-            composed: true,
-            bubbles: true,
-          })
-        );
+      this.restApiService.awaitPendingDiffDrafts().then(() => {
+        fireEvent(this, 'comment-refresh');
         this._savingComments = false;
       });
     }
@@ -548,13 +549,7 @@
             const moveTo = isReviewer ? 'reviewer' : 'CC';
             const id = account.name || account.email || key;
             const message = `${id} moved from ${moveFrom} to ${moveTo}.`;
-            this.dispatchEvent(
-              new CustomEvent('show-alert', {
-                detail: {message},
-                composed: true,
-                bubbles: true,
-              })
-            );
+            fireAlert(this, message);
           }
         }
       }
@@ -568,7 +563,7 @@
     for (const splice of indexSplices) {
       for (const account of splice.removed) {
         if (!this._reviewersPendingRemove[type]) {
-          console.error('Invalid type ' + type + ' for reviewer.');
+          this.reporting.error(new Error(`Invalid type ${type} for reviewer.`));
           return;
         }
         this._reviewersPendingRemove[type].push(account);
@@ -611,12 +606,12 @@
     account: AccountInfoInput | GroupInfoInput,
     type: ReviewerType
   ) {
-    if (!this.change) throw new Error('missing required change property');
+    assertIsDefined(this.change, 'change');
     if (account._pendingAdd || !isAccount(account)) {
       return;
     }
 
-    return this.$.restAPI
+    return this.restApiService
       .removeChangeReviewer(this.change._number, accountKey(account))
       .then((response?: Response) => {
         if (!response?.ok || !this.change) return;
@@ -686,17 +681,13 @@
     }
 
     if (this.draft) {
-      if (this._isPatchsetCommentsExperimentEnabled) {
-        const comment: CommentInput = {
-          message: this.draft,
-          unresolved: !this._isResolvedPatchsetLevelComment,
-        };
-        reviewInput.comments = {
-          [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [comment],
-        };
-      } else {
-        reviewInput.message = this.draft;
-      }
+      const comment: CommentInput = {
+        message: this.draft,
+        unresolved: !this._isResolvedPatchsetLevelComment,
+      };
+      reviewInput.comments = {
+        [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [comment],
+      };
     }
 
     const accountAdditions = new Map<AccountId | EmailAddress, boolean>();
@@ -729,13 +720,7 @@
           return new Map<AccountId | EmailAddress, boolean>();
         }
         if (!response.ok) {
-          this.dispatchEvent(
-            new CustomEvent('server-error', {
-              detail: {response},
-              composed: true,
-              bubbles: true,
-            })
-          );
+          fireServerError(response);
           return new Map<AccountId | EmailAddress, boolean>();
         }
 
@@ -798,13 +783,14 @@
     return account._account_id === change.owner._account_id;
   }
 
-  _handle400Error(response?: Response | null) {
-    if (!response) throw new Error('Reponse is empty.');
+  _handle400Error(r?: Response | null) {
+    if (!r) throw new Error('Response is empty.');
+    let response: Response = r;
     // A call to _saveReview could fail with a server error if erroneous
     // reviewers were requested. This is signalled with a 400 Bad Request
-    // status. The default gr-rest-api-interface error handling would
-    // result in a large JSON response body being displayed to the user in
-    // the gr-error-manager toast.
+    // status. The default gr-rest-api error handling would result in a large
+    // JSON response body being displayed to the user in the gr-error-manager
+    // toast.
     //
     // We can modify the error handling behavior by passing this function
     // through to restAPI as a custom error handling function. Since we're
@@ -816,12 +802,12 @@
     // Using response.clone() here, because getResponseObject() and
     // potentially the generic error handler will want to call text() on the
     // response object, which can only be done once per object.
-    const jsonPromise = this.$.restAPI.getResponseObject(response.clone());
+    const jsonPromise = this.restApiService.getResponseObject(response.clone());
     return jsonPromise.then((parsed: ParsedJSON) => {
       const result = parsed as ReviewResult;
       // Only perform custom error handling for 400s and a parseable
       // ReviewResult response.
-      if (response && response.status === 400 && result && result.reviewers) {
+      if (response.status === 400 && result && result.reviewers) {
         const errors: string[] = [];
         const addReviewers = Object.values(result.reviewers);
         addReviewers.forEach(r => errors.push(r.error ?? 'no explanation'));
@@ -831,13 +817,7 @@
           text: () => Promise.resolve(errors.join(', ')),
         };
       }
-      this.dispatchEvent(
-        new CustomEvent('server-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireServerError(response);
     });
   }
 
@@ -847,13 +827,7 @@
 
   _computeDraftsTitle(draftCommentThreads?: CommentThread[]) {
     const total = draftCommentThreads ? draftCommentThreads.length : 0;
-    if (total === 0) {
-      return '';
-    }
-    if (total === 1) {
-      return '1 Draft';
-    }
-    return `${total} Drafts`;
+    return pluralize(total, 'Draft');
   }
 
   _computeMessagePlaceholder(canBeStarted: boolean) {
@@ -912,9 +886,7 @@
   _onAttentionExpandedChange() {
     // If the attention-detail section is expanded without dispatching this
     // event, then the dialog may expand beyond the screen's bottom border.
-    this.dispatchEvent(
-      new CustomEvent('iron-resize', {composed: true, bubbles: true})
-    );
+    fireEvent(this, 'iron-resize');
   }
 
   _showAttentionSummary(config?: ServerInfo, attentionExpanded?: boolean) {
@@ -1027,7 +999,8 @@
       const notIsReviewerAndHasDraftOrLabel = (r: AccountInfo) =>
         !(r._account_id === currentUser._account_id && (hasDrafts || hasVote));
       reviewers.base
-        .filter(r => r._pendingAdd && r._account_id)
+        .filter(r => r._account_id)
+        .filter(r => r._pendingAdd || (this.canBeStarted && isOwner))
         .filter(notIsReviewerAndHasDraftOrLabel)
         .forEach(r => newAttention.add(r._account_id!));
       // Add owner and uploader, if someone else replies.
@@ -1127,8 +1100,11 @@
     if (containsAll(currentAttentionSet, newAttentionSet)) {
       return 'No additions to the attention set.';
     }
-    console.error(
-      '_computeDoNotUpdateMessage() should not be called when users were added to the attention set.'
+    this.reporting.error(
+      new Error(
+        '_computeDoNotUpdateMessage()' +
+          'should not be called when users were added to the attention set.'
+      )
     );
     return '';
   }
@@ -1229,7 +1205,7 @@
   }
 
   _getAccount() {
-    return this.$.restAPI.getAccount();
+    return this.restApiService.getAccount();
   }
 
   _cancelTapHandler(e: Event) {
@@ -1238,7 +1214,7 @@
   }
 
   cancel() {
-    if (!this.change) throw new Error('missing required change property');
+    assertIsDefined(this.change, 'change');
     if (!this._owner) throw new Error('missing required _owner property');
     this.dispatchEvent(
       new CustomEvent('cancel', {
@@ -1275,13 +1251,7 @@
       return;
     }
     if (this._sendDisabled) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          bubbles: true,
-          composed: true,
-          detail: {message: EMPTY_REPLY_MESSAGE},
-        })
-      );
+      fireAlert(this, EMPTY_REPLY_MESSAGE);
       return;
     }
     return this.send(this._includeComments, this.canBeStarted)
@@ -1300,9 +1270,9 @@
   }
 
   _saveReview(review: ReviewInput, errFn?: ErrorCallback) {
-    if (!this.change) throw new Error('missing required change property');
-    if (!this.patchNum) throw new Error('missing required patchNum property');
-    return this.$.restAPI.saveChangeReview(
+    assertIsDefined(this.change, 'change');
+    assertIsDefined(this.patchNum, 'patchNum');
+    return this.restApiService.saveChangeReview(
       this.change._number,
       this.patchNum,
       review,
@@ -1331,7 +1301,9 @@
       this._focusOn(FocusTarget.REVIEWERS);
       return;
     }
-    console.error('_confirmPendingReviewer called without pending confirm');
+    this.reporting.error(
+      new Error('_confirmPendingReviewer called without pending confirm')
+    );
   }
 
   _cancelPendingReviewer() {
@@ -1345,7 +1317,7 @@
   }
 
   _getStorageLocation(): StorageLocation {
-    if (!this.change) throw new Error('missing required change property');
+    assertIsDefined(this.change, 'change');
     return {
       changeNum: this.change._number,
       patchNum: '@change',
@@ -1354,7 +1326,7 @@
   }
 
   _loadStoredDraft() {
-    const draft = this.$.storage.getDraftComment(this._getStorageLocation());
+    const draft = this.storage.getDraftComment(this._getStorageLocation());
     return draft?.message ?? '';
   }
 
@@ -1368,17 +1340,14 @@
 
   _draftChanged(newDraft: string, oldDraft?: string) {
     this.debounce(
-      'store',
+      DEBOUNCER_STORE,
       () => {
         if (!newDraft.length && oldDraft) {
           // If the draft has been modified to be empty, then erase the storage
           // entry.
-          this.$.storage.eraseDraftComment(this._getStorageLocation());
+          this.storage.eraseDraftComment(this._getStorageLocation());
         } else if (newDraft.length) {
-          this.$.storage.setDraftComment(
-            this._getStorageLocation(),
-            this.draft
-          );
+          this.storage.setDraftComment(this._getStorageLocation(), this.draft);
         }
       },
       STORAGE_DEBOUNCE_INTERVAL_MS
@@ -1386,12 +1355,7 @@
   }
 
   _handleHeightChanged() {
-    this.dispatchEvent(
-      new CustomEvent('autogrow', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'autogrow');
   }
 
   _handleLabelsChanged() {
@@ -1420,7 +1384,10 @@
       : ButtonLabels.SEND;
   }
 
-  _computeSendButtonTooltip(canBeStarted: boolean) {
+  _computeSendButtonTooltip(canBeStarted?: boolean, commentEditing?: boolean) {
+    if (commentEditing) {
+      return ButtonTooltips.DISABLED_COMMENT_EDITING;
+    }
     return canBeStarted ? ButtonTooltips.START_REVIEW : ButtonTooltips.SEND;
   }
 
@@ -1436,7 +1403,9 @@
     labelsChanged?: boolean,
     includeComments?: boolean,
     disabled?: boolean,
-    commentEditing?: boolean
+    commentEditing?: boolean,
+    change?: ChangeInfo,
+    account?: AccountInfo
   ) {
     if (
       canBeStarted === undefined ||
@@ -1446,7 +1415,9 @@
       labelsChanged === undefined ||
       includeComments === undefined ||
       disabled === undefined ||
-      commentEditing === undefined
+      commentEditing === undefined ||
+      change?.labels === undefined ||
+      account === undefined
     ) {
       return undefined;
     }
@@ -1456,8 +1427,14 @@
     if (canBeStarted === true) {
       return false;
     }
+    const existingVote = Object.values(change.labels).some(
+      label => isDetailedLabelInfo(label) && getApprovalInfo(label, account)
+    );
+    const revotingOrNewVote = labelsChanged || existingVote;
     const hasDrafts = includeComments && draftCommentThreads.length;
-    return !hasDrafts && !text.length && !reviewersMutated && !labelsChanged;
+    return (
+      !hasDrafts && !text.length && !reviewersMutated && !revotingOrNewVote
+    );
   }
 
   _computePatchSetWarning(patchNum?: PatchSetNum, labelsChanged?: boolean) {
@@ -1478,7 +1455,7 @@
 
   _getReviewerSuggestionsProvider(change: ChangeInfo) {
     const provider = GrReviewerSuggestionsProvider.create(
-      this.$.restAPI,
+      this.restApiService,
       change._number,
       SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER
     );
@@ -1488,7 +1465,7 @@
 
   _getCcSuggestionsProvider(change: ChangeInfo) {
     const provider = GrReviewerSuggestionsProvider.create(
-      this.$.restAPI,
+      this.restApiService,
       change._number,
       SUGGESTIONS_PROVIDERS_USERS_TYPES.CC
     );
@@ -1496,20 +1473,6 @@
     return provider;
   }
 
-  _onThreadListModified() {
-    // TODO(taoalpha): this won't propogate the changes to the files
-    // should consider replacing this with either top level events
-    // or gerrit level events
-
-    // emit the event so change-view can also get updated with latest changes
-    this.dispatchEvent(
-      new CustomEvent('comment-refresh', {
-        composed: true,
-        bubbles: true,
-      })
-    );
-  }
-
   reportAttentionSetChanges(
     modified: boolean,
     addedSet?: AttentionSetInput[],
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
index c56a5c9..79569bc 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
@@ -176,13 +176,9 @@
          great solution. */
       line-height: 26px;
     }
-    .attention-detail .peopleList .accountList {
-      display: flex;
-      flex-wrap: wrap;
-    }
     .attentionSummary gr-account-label,
     .attention-detail gr-account-label {
-      --account-max-length: 150px;
+      --account-max-length: 120px;
       display: inline-block;
       padding: var(--spacing-xs) var(--spacing-m);
       user-select: none;
@@ -193,8 +189,11 @@
       line-height: var(--line-height-normal);
       vertical-align: top;
     }
+    .attention-detail .peopleListValues {
+      line-height: calc(var(--line-height-normal) + 10px);
+    }
     .attention-detail gr-account-label {
-      vertical-align: baseline;
+      line-height: var(--line-height-normal);
     }
     .attentionSummary gr-account-label:focus,
     .attention-detail gr-account-label:focus {
@@ -303,16 +302,14 @@
       </gr-endpoint-decorator>
     </section>
     <section class="previewContainer">
-      <template is="dom-if" if="[[_isPatchsetCommentsExperimentEnabled]]">
-        <label>
-          <input
-            id="resolvedPatchsetLevelCommentCheckbox"
-            type="checkbox"
-            checked="{{_isResolvedPatchsetLevelComment::change}}"
-          />
-          Resolved
-        </label>
-      </template>
+      <label>
+        <input
+          id="resolvedPatchsetLevelCommentCheckbox"
+          type="checkbox"
+          checked="{{_isResolvedPatchsetLevelComment::change}}"
+        />
+        Resolved
+      </label>
       <label class="preview-formatting">
         <input type="checkbox" checked="{{_previewFormatting::change}}" />
         Preview formatting
@@ -357,7 +354,6 @@
         change-num="[[change._number]]"
         logged-in="true"
         hide-toggle-buttons=""
-        on-thread-list-modified="_onThreadListModified"
       >
       </gr-thread-list>
       <span
@@ -396,8 +392,8 @@
                 <gr-account-label
                   account="[[account]]"
                   force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                  selected$="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                  deselected$="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
+                  selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
+                  deselected="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
                   hide-hovercard=""
                   on-click="_handleAttentionClick"
                 ></gr-account-label>
@@ -423,15 +419,6 @@
           </div>
           <div>
             <a
-              href="https://bugs.chromium.org/p/gerrit/issues/entry?template=Attention+Set"
-              target="_blank"
-            >
-              <iron-icon
-                icon="gr-icons:bug"
-                title="report a problem"
-              ></iron-icon>
-            </a>
-            <a
               href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
               target="_blank"
             >
@@ -454,15 +441,6 @@
           <div></div>
           <div>
             <a
-              href="https://bugs.chromium.org/p/gerrit/issues/entry?template=Attention+Set"
-              target="_blank"
-            >
-              <iron-icon
-                icon="gr-icons:bug"
-                title="report a problem"
-              ></iron-icon>
-            </a>
-            <a
               href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
               target="_blank"
             >
@@ -481,12 +459,12 @@
         </div>
         <div class="peopleList">
           <div class="peopleListLabel">Owner</div>
-          <div>
+          <div class="peopleListValues">
             <gr-account-label
               account="[[_owner]]"
               force-attention="[[_computeHasNewAttention(_owner, _newAttentionSet)]]"
-              selected$="[[_computeHasNewAttention(_owner, _newAttentionSet)]]"
-              deselected$="[[!_computeHasNewAttention(_owner, _newAttentionSet)]]"
+              selected="[[_computeHasNewAttention(_owner, _newAttentionSet)]]"
+              deselected="[[!_computeHasNewAttention(_owner, _newAttentionSet)]]"
               hide-hovercard=""
               on-click="_handleAttentionClick"
             >
@@ -496,12 +474,12 @@
         <template is="dom-if" if="[[_uploader]]">
           <div class="peopleList">
             <div class="peopleListLabel">Uploader</div>
-            <div>
+            <div class="peopleListValues">
               <gr-account-label
                 account="[[_uploader]]"
                 force-attention="[[_computeHasNewAttention(_uploader, _newAttentionSet)]]"
-                selected$="[[_computeHasNewAttention(_uploader, _newAttentionSet)]]"
-                deselected$="[[!_computeHasNewAttention(_uploader, _newAttentionSet)]]"
+                selected="[[_computeHasNewAttention(_uploader, _newAttentionSet)]]"
+                deselected="[[!_computeHasNewAttention(_uploader, _newAttentionSet)]]"
                 hide-hovercard=""
                 on-click="_handleAttentionClick"
               >
@@ -511,7 +489,7 @@
         </template>
         <div class="peopleList">
           <div class="peopleListLabel">Reviewers</div>
-          <div>
+          <div class="peopleListValues">
             <template
               is="dom-repeat"
               items="[[_removeServiceUsers(_reviewers, _newAttentionSet)]]"
@@ -520,8 +498,8 @@
               <gr-account-label
                 account="[[account]]"
                 force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                selected$="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                deselected$="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
+                selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
+                deselected="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
                 hide-hovercard=""
                 on-click="_handleAttentionClick"
               >
@@ -532,7 +510,7 @@
         <template is="dom-if" if="[[_attentionCcsCount]]">
           <div class="peopleList">
             <div class="peopleListLabel">CC</div>
-            <div>
+            <div class="peopleListValues">
               <template
                 is="dom-repeat"
                 items="[[_removeServiceUsers(_ccs, _newAttentionSet)]]"
@@ -541,8 +519,8 @@
                 <gr-account-label
                   account="[[account]]"
                   force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                  selected$="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                  deselected$="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
+                  selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
+                  deselected="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
                   hide-hovercard=""
                   on-click="_handleAttentionClick"
                 >
@@ -599,7 +577,7 @@
               has-tooltip=""
               title="[[_saveTooltip]]"
               on-click="_saveClickHandler"
-              >Save</gr-button
+              >Send As WIP</gr-button
             >
           </template>
           <gr-button
@@ -608,7 +586,7 @@
             disabled="[[_sendDisabled]]"
             class="action send"
             has-tooltip=""
-            title$="[[_computeSendButtonTooltip(canBeStarted)]]"
+            title$="[[_computeSendButtonTooltip(canBeStarted, _commentEditing)]]"
             on-click="_sendTapHandler"
             >[[_sendButtonLabel]]</gr-button
           >
@@ -616,7 +594,4 @@
       </section>
     </div>
   </div>
-  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  <gr-storage id="storage"></gr-storage>
 `;
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 b612a57..0575239 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
@@ -21,6 +21,11 @@
 import {mockPromise} from '../../../test/test-utils.js';
 import {SpecialFilePath} from '../../../constants/constants.js';
 import {appContext} from '../../../services/app-context.js';
+import {addListenerForTest} from '../../../test/test-utils.js';
+import {stubRestApi} from '../../../test/test-utils.js';
+import {JSON_PREFIX} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js';
+import {CODE_REVIEW} from '../../../utils/label-util.js';
+import {createAccountWithId} from '../../../test/test-data-generators.js';
 
 const basicFixture = fixtureFromElement('gr-reply-dialog');
 
@@ -60,12 +65,10 @@
     changeNum = 42;
     patchNum = 1;
 
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-      getAccount() { return Promise.resolve({}); },
-      getChange() { return Promise.resolve({}); },
-      getChangeSuggestedReviewers() { return Promise.resolve([]); },
-    });
+    stubRestApi('getConfig').returns(Promise.resolve({}));
+    stubRestApi('getAccount').returns(Promise.resolve({}));
+    stubRestApi('getChange').returns(Promise.resolve({}));
+    stubRestApi('getChangeSuggestedReviewers').returns(Promise.resolve([]));
 
     sinon.stub(appContext.flagsService, 'isEnabled').returns(true);
 
@@ -111,10 +114,9 @@
       ],
     };
 
-    getDraftCommentStub = sinon.stub(element.$.storage, 'getDraftComment');
-    setDraftCommentStub = sinon.stub(element.$.storage, 'setDraftComment');
-    eraseDraftCommentStub = sinon.stub(element.$.storage,
-        'eraseDraftComment');
+    getDraftCommentStub = sinon.stub(element.storage, 'getDraftComment');
+    setDraftCommentStub = sinon.stub(element.storage, 'setDraftComment');
+    eraseDraftCommentStub = sinon.stub(element.storage, 'eraseDraftComment');
 
     // sinon.stub(patchSetUtilMockProxy, 'fetchChangeUpdates')
     //     .returns(Promise.resolve({isLatest: true}));
@@ -130,8 +132,7 @@
         .callsFake(review => new Promise((resolve, reject) => {
           try {
             const result = jsonResponseProducer(review) || {};
-            const resultStr =
-            element.$.restAPI.JSON_PREFIX + JSON.stringify(result);
+            const resultStr = JSON_PREFIX + JSON.stringify(result);
             resolve({
               ok: true,
               text() {
@@ -313,6 +314,39 @@
     assert.sameMembers([...element._newAttentionSet], [2, 5]);
   });
 
+  test('computeNewAttention when sending wip change for review', () => {
+    const reviewers = {base: [
+      {_account_id: 2},
+      {_account_id: 3},
+    ]};
+    const change = {
+      owner: {_account_id: 1},
+      status: 'NEW',
+      attention_set: {},
+    };
+    element.change = change;
+    element._reviewers = reviewers.base;
+    flush();
+
+    // For an active change there is no reason to add anyone to the set.
+    let user = {_account_id: 1};
+    element._computeNewAttention(user, reviewers, [], change, [], false);
+    assert.sameMembers([...element._newAttentionSet], []);
+
+    // If the change is "work in progress" and the owner sends a reply, then
+    // add all reviewers.
+    element.canBeStarted = true;
+    flush();
+    user = {_account_id: 1};
+    element._computeNewAttention(user, reviewers, [], change, [], false);
+    assert.sameMembers([...element._newAttentionSet], [2, 3]);
+
+    // ... but not when someone else replies.
+    user = {_account_id: 4};
+    element._computeNewAttention(user, reviewers, [], change, [], false);
+    assert.sameMembers([...element._newAttentionSet], []);
+  });
+
   test('computeNewAttentionAccounts', () => {
     element._reviewers = [
       {_account_id: 123, display_name: 'Ernie'},
@@ -778,40 +812,47 @@
     assert.isTrue(eraseDraftCommentStub.calledWith(location));
   });
 
-  test('400 converts to human-readable server-error', async () => {
-    sinon.stub(window, 'fetch').callsFake(() => {
-      const text = '....{"reviewers":{"id1":{"error":"human readable"}}}';
-      return Promise.resolve(cloneableResponse(400, text));
-    });
+  test('400 converts to human-readable server-error', done => {
+    stubRestApi('saveChangeReview').callsFake(
+        (changeNum, patchNum, review, errFn) => {
+          errFn(cloneableResponse(
+              400,
+              '....{"reviewers":{"id1":{"error":"human readable"}}}'
+          ));
+          return Promise.resolve(undefined);
+        }
+    );
 
-    let resolver;
-    const promise = new Promise(r => resolver = r);
-    element.addEventListener('server-error', resolver);
+    const listener = event => {
+      if (event.target !== document) return;
+      event.detail.response.text().then(body => {
+        if (body === 'human readable') {
+          done();
+        }
+      });
+    };
+    addListenerForTest(document, 'server-error', listener);
 
-    await flush();
-    element.send();
-
-    const event = await promise;
-    assert.equal(event.target, element);
-    const text = await event.detail.response.text();
-    assert.equal(text, 'human readable');
+    flush(() => { element.send(); });
   });
 
   test('non-json 400 is treated as a normal server-error', done => {
-    sinon.stub(window, 'fetch').callsFake(() => {
-      const text = 'Comment validation error!';
-      return Promise.resolve(cloneableResponse(400, text));
-    });
+    stubRestApi('saveChangeReview').callsFake(
+        (changeNum, patchNum, review, errFn) => {
+          errFn(cloneableResponse(400, 'Comment validation error!'));
+          return Promise.resolve(undefined);
+        }
+    );
 
-    element.addEventListener('server-error', event => {
-      if (event.target !== element) {
-        return;
-      }
+    const listener = event => {
+      if (event.target !== document) return;
       event.detail.response.text().then(body => {
-        assert.equal(body, 'Comment validation error!');
-        done();
+        if (body === 'Comment validation error!') {
+          done();
+        }
       });
-    });
+    };
+    addListenerForTest(document, 'server-error', listener);
 
     // Async tick is needed because iron-selector content is distributed and
     // distributed content requires an observer to be set up.
@@ -960,8 +1001,8 @@
       };
     };
     const checkObjEmpty = function(obj) {
-      for (const prop in obj) {
-        if (obj.hasOwnProperty(prop) && obj[prop].length) { return false; }
+      for (const prop of Object.keys(obj)) {
+        if (obj[prop].length) { return false; }
       }
       return true;
     };
@@ -977,7 +1018,7 @@
   });
 
   test('_removeAccount', done => {
-    sinon.stub(element.$.restAPI, 'removeChangeReviewer')
+    stubRestApi('removeChangeReviewer')
         .returns(Promise.resolve({ok: true}));
     const arr = [makeAccount(), makeAccount()];
     element.change.reviewers = {
@@ -1235,7 +1276,7 @@
         'Send and Start review');
   });
 
-  test('_handle400Error reviewrs and CCs', done => {
+  test('_handle400Error reviewers and CCs', done => {
     const error1 = 'error 1';
     const error2 = 'error 2';
     const error3 = 'error 3';
@@ -1255,12 +1296,13 @@
         },
       },
     });
-    element.addEventListener('server-error', e => {
+    const listener = e => {
       e.detail.response.text().then(text => {
         assert.equal(text, [error1, error2, error3].join(', '));
         done();
       });
-    });
+    };
+    addListenerForTest(document, 'server-error', listener);
     element._handle400Error(cloneableResponse(400, text));
   });
 
@@ -1278,36 +1320,6 @@
     });
   });
 
-  suite('post review API', () => {
-    let startReviewStub;
-
-    setup(() => {
-      startReviewStub = sinon.stub(
-          element.$.restAPI,
-          'startReview')
-          .callsFake(() => Promise.resolve());
-    });
-
-    test('ready property in review input on start review', () => {
-      stubSaveReview(review => {
-        assert.isTrue(review.ready);
-        return {ready: true};
-      });
-      return element.send(true, true).then(() => {
-        assert.isFalse(startReviewStub.called);
-      });
-    });
-
-    test('no ready property in review input on save review', () => {
-      stubSaveReview(review => {
-        assert.isUndefined(review.ready);
-      });
-      return element.send(true, false).then(() => {
-        assert.isFalse(startReviewStub.called);
-      });
-    });
-  });
-
   suite('start review and save buttons', () => {
     let sendStub;
 
@@ -1366,38 +1378,36 @@
     });
 
     suite('pending diff drafts?', () => {
-      test('yes', () => {
+      test('yes', async () => {
         const promise = mockPromise();
-        const refreshHandler = sinon.stub();
+        const refreshSpy = sinon.spy();
+        element.addEventListener('comment-refresh', refreshSpy);
+        stubRestApi('hasPendingDiffDrafts').returns(true);
+        stubRestApi('awaitPendingDiffDrafts').returns(promise);
 
-        element.addEventListener('comment-refresh', refreshHandler);
-        sinon.stub(element.$.restAPI, 'hasPendingDiffDrafts').returns(true);
-        element.$.restAPI._pendingRequests.sendDiffDraft = [promise];
         element.open();
 
-        assert.isFalse(refreshHandler.called);
+        assert.isFalse(refreshSpy.called);
         assert.isTrue(element._savingComments);
 
         promise.resolve();
+        await flush();
 
-        return element.$.restAPI.awaitPendingDiffDrafts().then(() => {
-          assert.isTrue(refreshHandler.called);
-          assert.isFalse(element._savingComments);
-        });
+        assert.isTrue(refreshSpy.called);
+        assert.isFalse(element._savingComments);
       });
 
       test('no', () => {
-        sinon.stub(element.$.restAPI, 'hasPendingDiffDrafts').returns(false);
+        stubRestApi('hasPendingDiffDrafts').returns(false);
         element.open();
-        assert.notOk(element._savingComments);
+        assert.isFalse(element._savingComments);
       });
     });
   });
 
   test('_computeSendButtonDisabled_canBeStarted', () => {
-    const fn = element._computeSendButtonDisabled.bind(element);
     // Mock canBeStarted
-    assert.isFalse(fn(
+    assert.isFalse(element._computeSendButtonDisabled(
         /* canBeStarted= */ true,
         /* draftCommentThreads= */ [],
         /* text= */ '',
@@ -1405,14 +1415,15 @@
         /* labelsChanged= */ false,
         /* includeComments= */ false,
         /* disabled= */ false,
-        /* commentEditing= */ false
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ makeAccount()
     ));
   });
 
   test('_computeSendButtonDisabled_allFalse', () => {
-    const fn = element._computeSendButtonDisabled.bind(element);
     // Mock everything false
-    assert.isTrue(fn(
+    assert.isTrue(element._computeSendButtonDisabled(
         /* canBeStarted= */ false,
         /* draftCommentThreads= */ [],
         /* text= */ '',
@@ -1420,14 +1431,15 @@
         /* labelsChanged= */ false,
         /* includeComments= */ false,
         /* disabled= */ false,
-        /* commentEditing= */ false
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ makeAccount()
     ));
   });
 
   test('_computeSendButtonDisabled_draftCommentsSend', () => {
-    const fn = element._computeSendButtonDisabled.bind(element);
     // Mock nonempty comment draft array, with sending comments.
-    assert.isFalse(fn(
+    assert.isFalse(element._computeSendButtonDisabled(
         /* canBeStarted= */ false,
         /* draftCommentThreads= */ [{comments: [{__draft: true}]}],
         /* text= */ '',
@@ -1435,14 +1447,15 @@
         /* labelsChanged= */ false,
         /* includeComments= */ true,
         /* disabled= */ false,
-        /* commentEditing= */ false
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ makeAccount()
     ));
   });
 
   test('_computeSendButtonDisabled_draftCommentsDoNotSend', () => {
-    const fn = element._computeSendButtonDisabled.bind(element);
     // Mock nonempty comment draft array, without sending comments.
-    assert.isTrue(fn(
+    assert.isTrue(element._computeSendButtonDisabled(
         /* canBeStarted= */ false,
         /* draftCommentThreads= */ [{comments: [{__draft: true}]}],
         /* text= */ '',
@@ -1450,14 +1463,15 @@
         /* labelsChanged= */ false,
         /* includeComments= */ false,
         /* disabled= */ false,
-        /* commentEditing= */ false
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ makeAccount()
     ));
   });
 
   test('_computeSendButtonDisabled_changeMessage', () => {
-    const fn = element._computeSendButtonDisabled.bind(element);
     // Mock nonempty change message.
-    assert.isFalse(fn(
+    assert.isFalse(element._computeSendButtonDisabled(
         /* canBeStarted= */ false,
         /* draftCommentThreads= */ {},
         /* text= */ 'test',
@@ -1465,14 +1479,15 @@
         /* labelsChanged= */ false,
         /* includeComments= */ false,
         /* disabled= */ false,
-        /* commentEditing= */ false
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ makeAccount()
     ));
   });
 
   test('_computeSendButtonDisabled_reviewersChanged', () => {
-    const fn = element._computeSendButtonDisabled.bind(element);
     // Mock reviewers mutated.
-    assert.isFalse(fn(
+    assert.isFalse(element._computeSendButtonDisabled(
         /* canBeStarted= */ false,
         /* draftCommentThreads= */ {},
         /* text= */ '',
@@ -1480,14 +1495,15 @@
         /* labelsChanged= */ false,
         /* includeComments= */ false,
         /* disabled= */ false,
-        /* commentEditing= */ false
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ makeAccount()
     ));
   });
 
   test('_computeSendButtonDisabled_labelsChanged', () => {
-    const fn = element._computeSendButtonDisabled.bind(element);
     // Mock labels changed.
-    assert.isFalse(fn(
+    assert.isFalse(element._computeSendButtonDisabled(
         /* canBeStarted= */ false,
         /* draftCommentThreads= */ {},
         /* text= */ '',
@@ -1495,14 +1511,15 @@
         /* labelsChanged= */ true,
         /* includeComments= */ false,
         /* disabled= */ false,
-        /* commentEditing= */ false
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ makeAccount()
     ));
   });
 
   test('_computeSendButtonDisabled_dialogDisabled', () => {
-    const fn = element._computeSendButtonDisabled.bind(element);
     // Whole dialog is disabled.
-    assert.isTrue(fn(
+    assert.isTrue(element._computeSendButtonDisabled(
         /* canBeStarted= */ false,
         /* draftCommentThreads= */ {},
         /* text= */ '',
@@ -1510,27 +1527,40 @@
         /* labelsChanged= */ true,
         /* includeComments= */ false,
         /* disabled= */ true,
-        /* commentEditing= */ false
-    ));
-    assert.isTrue(fn(
-        /* buttonLabel= */ 'Send',
-        /* draftCommentThreads= */ {},
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ true,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ true
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ makeAccount()
     ));
   });
 
-  test('_submit blocked when no mutations exist', () => {
+  test('_computeSendButtonDisabled_existingVote', async () => {
+    const account = createAccountWithId();
+    element.change.labels[CODE_REVIEW].all = [account];
+    await flush();
+
+    // User has already voted.
+    assert.isFalse(element._computeSendButtonDisabled(
+        /* canBeStarted= */ false,
+        /* draftCommentThreads= */ {},
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ false,
+        /* includeComments= */ false,
+        /* disabled= */ false,
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ account
+    ));
+  });
+
+  test('_submit blocked when no mutations exist', async () => {
     const sendStub = sinon.stub(element, 'send').returns(Promise.resolve());
     // Stub the below function to avoid side effects from the send promise
     // resolving.
     sinon.stub(element, '_purgeReviewersPendingRemove');
+    element.account = makeAccount();
     element.draftCommentThreads = [];
-    flush();
+    await flush();
 
     MockInteractions.tap(element.shadowRoot
         .querySelector('gr-button.send'));
@@ -1539,21 +1569,26 @@
     element.draftCommentThreads = [{comments: [
       {__draft: true, path: 'test', line: 1, patch_set: 1},
     ]}];
-    flush();
+    await flush();
 
     MockInteractions.tap(element.shadowRoot
         .querySelector('gr-button.send'));
     assert.isTrue(sendStub.called);
   });
 
-  test('getFocusStops', () => {
+  test('getFocusStops', async () => {
     // Setting draftCommentThreads to an empty object causes _sendDisabled to be
     // computed to false.
     element.draftCommentThreads = [];
+    element.account = makeAccount();
+    await flush();
+
     assert.equal(element.getFocusStops().end, element.$.cancelButton);
-    element.draftCommentThreads = [{comments: [
-      {__draft: true, path: 'test', line: 1, patch_set: 1},
-    ]}];
+    element.draftCommentThreads = [
+      {comments: [{__draft: true, path: 'test', line: 1, patch_set: 1}]},
+    ];
+    await flush();
+
     assert.equal(element.getFocusStops().end, element.$.sendButton);
   });
 
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
index 9c3fa42..30931c3 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
@@ -16,7 +16,6 @@
  */
 import '../../shared/gr-account-chip/gr-account-chip';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/shared-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
@@ -39,16 +38,11 @@
 } from '../../../types/common';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {isRemovableReviewer} from '../../../utils/change-util';
 import {ReviewerState} from '../../../constants/constants';
-
-export interface GrReviewerList {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
+import {appContext} from '../../../services/app-context';
+import {KnownExperimentId} from '../../../services/flags/flags';
 
 @customElement('gr-reviewer-list')
 export class GrReviewerList extends GestureEventListeners(
@@ -94,6 +88,21 @@
   @property({type: Object})
   _xhrPromise?: Promise<Response | undefined>;
 
+  @property({type: Boolean})
+  _isNewChangeSummaryUiEnabled = false;
+
+  private readonly restApiService = appContext.restApiService;
+
+  private readonly flagsService = appContext.flagsService;
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._isNewChangeSummaryUiEnabled = this.flagsService.isEnabled(
+      KnownExperimentId.NEW_CHANGE_SUMMARY_UI
+    );
+  }
+
   @computed('ccsOnly')
   get _addLabel() {
     return this.ccsOnly ? 'Add CC' : 'Add reviewer';
@@ -222,7 +231,7 @@
     }
     let result: AccountInfo[] = [];
     const reviewers = changeRecord.base;
-    for (const key in reviewers) {
+    for (const key of Object.keys(reviewers)) {
       if (this.reviewersOnly && key !== 'REVIEWER') {
         continue;
       }
@@ -323,6 +332,6 @@
 
   _removeReviewer(id: AccountId | EmailAddress): Promise<Response | undefined> {
     if (!this.change) return Promise.resolve(undefined);
-    return this.$.restAPI.removeChangeReviewer(this.change._number, id);
+    return this.restApiService.removeChangeReviewer(this.change._number, id);
   }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.ts
index 616a7db..c7c5efa 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.ts
@@ -27,13 +27,31 @@
     }
     .container {
       display: block;
+      /* line-height-normal for the chips, 2px for the chip border, spacing-s
+         for the gap between lines, negative bottom margin for eliminating the
+         gap after the last line */
+      line-height: calc(var(--line-height-normal) + 2px + var(--spacing-s));
+      margin-bottom: calc(0px - var(--spacing-s));
+    }
+    .addReviewer iron-icon {
+      color: inherit;
+      --iron-icon-height: 18px;
+      --iron-icon-width: 18px;
+    }
+    gr-button.addReviewer.new-change-summary-true {
+      --padding: 1px 4px;
+      vertical-align: top;
+      top: 1px;
     }
     gr-button {
+      line-height: var(--line-height-normal);
       --gr-button: {
         padding: 0px 0px;
       }
     }
     gr-account-chip {
+      line-height: var(--line-height-normal);
+      vertical-align: top;
       display: inline-block;
     }
   </style>
@@ -51,6 +69,16 @@
         >
         </gr-account-chip>
       </template>
+      <template is="dom-if" if="[[_isNewChangeSummaryUiEnabled]]">
+        <gr-button
+          link=""
+          id="addReviewer"
+          class="addReviewer new-change-summary-true"
+          on-click="_handleAddTap"
+          title="[[_addLabel]]"
+          ><iron-icon icon="gr-icons:edit"></iron-icon
+        ></gr-button>
+      </template>
     </div>
     <gr-button
       class="hiddenReviewers"
@@ -59,15 +87,16 @@
       on-click="_handleViewAll"
       >and [[_hiddenReviewerCount]] more</gr-button
     >
-    <div class="controlsContainer" hidden$="[[!mutable]]">
-      <gr-button
-        link=""
-        id="addReviewer"
-        class="addReviewer"
-        on-click="_handleAddTap"
-        >[[_addLabel]]</gr-button
-      >
-    </div>
+    <template is="dom-if" if="[[!_isNewChangeSummaryUiEnabled]]">
+      <div class="controlsContainer" hidden$="[[!mutable]]">
+        <gr-button
+          link=""
+          id="addReviewer"
+          class="addReviewer"
+          on-click="_handleAddTap"
+          >[[_addLabel]]</gr-button
+        >
+      </div>
+    </template>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
index d29abfc..f77f736 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-reviewer-list.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-reviewer-list');
 
@@ -27,15 +28,12 @@
     element = basicFixture.instantiate();
     element.serverConfig = {};
 
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-      removeChangeReviewer() {
-        return Promise.resolve({ok: true});
-      },
-    });
+    stubRestApi('getConfig').returns(Promise.resolve({}));
+    stubRestApi('removeChangeReviewer').returns(Promise.resolve({ok: true}));
   });
 
   test('controls hidden on immutable element', () => {
+    flush();
     element.mutable = false;
     assert.isTrue(element.shadowRoot
         .querySelector('.controlsContainer').hasAttribute('hidden'));
@@ -48,6 +46,7 @@
     element.addEventListener('show-reply-dialog', () => {
       done();
     });
+    flush();
     MockInteractions.tap(element.shadowRoot
         .querySelector('.addReviewer'));
   });
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
index 6a32834..f0ec7cd 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -31,7 +31,20 @@
   PolymerDeepPropertyChange,
 } from '@polymer/polymer/interfaces';
 import {ChangeInfo} from '../../../types/common';
-import {CommentThread, isDraft, UIRobot} from '../../../utils/comment-util';
+import {
+  CommentThread,
+  isDraft,
+  isUnresolved,
+  isDraftThread,
+  isRobotThread,
+  hasHumanReply,
+} from '../../../utils/comment-util';
+import {pluralize} from '../../../utils/string-util';
+import {fireThreadListModifiedEvent} from '../../../utils/event-util';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {appContext} from '../../../services/app-context';
+import {assertNever} from '../../../utils/common-util';
+import {CommentTabState} from '../../../types/events';
 
 interface CommentThreadWithInfo {
   thread: CommentThread;
@@ -66,6 +79,9 @@
   @property({type: Array})
   _sortedThreads: CommentThread[] = [];
 
+  @property({type: Boolean})
+  showCommentContext = false;
+
   @property({
     computed:
       '_computeDisplayedThreads(_sortedThreads.*, unresolvedOnly, ' +
@@ -89,6 +105,22 @@
   @property({type: Boolean})
   hideToggleButtons = false;
 
+  @property({type: Boolean})
+  _isNewChangeSummaryUiEnabled = false;
+
+  @property({type: Object, observer: '_commentTabStateChange'})
+  commentTabState?: CommentTabState;
+
+  flagsService = appContext.flagsService;
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._isNewChangeSummaryUiEnabled = this.flagsService.isEnabled(
+      KnownExperimentId.NEW_CHANGE_SUMMARY_UI
+    );
+  }
+
   _computeShowDraftToggle(loggedIn?: boolean) {
     return loggedIn ? 'show' : '';
   }
@@ -113,13 +145,14 @@
   _computeResolvedCommentsMessage(
     threads: CommentThread[],
     displayedThreads: CommentThread[],
-    unresolvedOnly: boolean
+    unresolvedOnly: boolean,
+    onlyShowRobotCommentsWithHumanReply: boolean
   ) {
+    if (onlyShowRobotCommentsWithHumanReply) {
+      threads = this.filterRobotThreadsWithoutHumanReply(threads) ?? [];
+    }
     if (unresolvedOnly && threads.length && !displayedThreads.length) {
-      return (
-        `Show ${threads.length} resolved comment` +
-        (threads.length > 1 ? 's' : '')
-      );
+      return `Show ${pluralize(threads.length, 'resolved comment')}`;
     }
     return '';
   }
@@ -161,7 +194,9 @@
     if (c1.thread.patchNum !== c2.thread.patchNum) {
       if (!c1.thread.patchNum) return 1;
       if (!c2.thread.patchNum) return -1;
-      // TODO(TS): Explicit comparison for 'edit' and 'PARENT' missing?
+      // Threads left on Base when comparing Base vs X have patchNum = X
+      // and CommentSide = PARENT
+      // Threads left on 'edit' have patchNum set as latestPatchNum
       return c1.thread.patchNum > c2.thread.patchNum ? -1 : 1;
     }
 
@@ -379,15 +414,9 @@
     const lastComment = comments.length
       ? comments[comments.length - 1]
       : undefined;
-    let hasRobotComment = false;
-    let hasHumanReplyToRobotComment = false;
-    comments.forEach(comment => {
-      if ((comment as UIRobot).robot_id) {
-        hasRobotComment = true;
-      } else if (hasRobotComment) {
-        hasHumanReplyToRobotComment = true;
-      }
-    });
+    const hasRobotComment = isRobotThread(thread);
+    const hasHumanReplyToRobotComment =
+      hasRobotComment && hasHumanReply(thread);
     let updated = undefined;
     if (lastComment) {
       if (isDraft(lastComment)) updated = lastComment.__date;
@@ -399,7 +428,7 @@
       hasRobotComment,
       hasHumanReplyToRobotComment,
       unresolved: !!lastComment && !!lastComment.unresolved,
-      isEditing: !!lastComment && !!lastComment.__editing,
+      isEditing: isDraft(lastComment) && !!lastComment.__editing,
       hasDraft: !!lastComment && isDraft(lastComment),
       updated,
     };
@@ -421,11 +450,7 @@
   }
 
   _handleCommentsChanged(e: CustomEvent) {
-    this.dispatchEvent(
-      new CustomEvent('thread-list-modified', {
-        detail: {rootId: e.detail.rootId, path: e.detail.path},
-      })
-    );
+    fireThreadListModifiedEvent(this, e.detail.rootId, e.detail.path);
   }
 
   _isOnParent(side?: CommentSide) {
@@ -434,12 +459,73 @@
     return !!side;
   }
 
+  _handleOnlyUnresolved() {
+    this.unresolvedOnly = true;
+    this._draftsOnly = false;
+  }
+
+  _handleOnlyDrafts() {
+    this._draftsOnly = true;
+    this.unresolvedOnly = false;
+  }
+
+  _handleAllComments() {
+    this._draftsOnly = false;
+    this.unresolvedOnly = false;
+  }
+
+  _showAllComments(draftsOnly?: boolean, unresolvedOnly?: boolean) {
+    return !draftsOnly && !unresolvedOnly;
+  }
+
+  _countUnresolved(threads?: CommentThread[]) {
+    return (
+      this.filterRobotThreadsWithoutHumanReply(threads)?.filter(isUnresolved)
+        .length ?? 0
+    );
+  }
+
+  _countAllThreads(threads?: CommentThread[]) {
+    return this.filterRobotThreadsWithoutHumanReply(threads)?.length ?? 0;
+  }
+
+  _countDrafts(threads?: CommentThread[]) {
+    return (
+      this.filterRobotThreadsWithoutHumanReply(threads)?.filter(isDraftThread)
+        .length ?? 0
+    );
+  }
+
   /**
    * Work around a issue on iOS when clicking turns into double tap
    */
   _onTapUnresolvedToggle(e: Event) {
     e.preventDefault();
   }
+
+  filterRobotThreadsWithoutHumanReply(threads?: CommentThread[]) {
+    return threads?.filter(t => !isRobotThread(t) || hasHumanReply(t));
+  }
+
+  _commentTabStateChange(
+    newValue?: CommentTabState,
+    oldValue?: CommentTabState
+  ) {
+    if (!newValue || newValue === oldValue) return;
+    switch (newValue) {
+      case CommentTabState.UNRESOLVED:
+        this._handleOnlyUnresolved();
+        break;
+      case CommentTabState.DRAFTS:
+        this._handleOnlyDrafts();
+        break;
+      case CommentTabState.SHOW_ALL:
+        this._handleAllComments();
+        break;
+      default:
+        assertNever(newValue, 'Unsupported preferred state');
+    }
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
index e55f98a..8b1ba25 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
@@ -25,16 +25,14 @@
     gr-comment-thread {
       display: block;
       margin-bottom: var(--spacing-m);
-      max-width: 80ch;
     }
     .header {
       align-items: center;
-      background-color: var(--table-header-background-color);
+      background-color: var(--background-color-primary);
       border-bottom: 1px solid var(--border-color);
       border-top: 1px solid var(--border-color);
       display: flex;
       justify-content: left;
-      min-height: 3.2em;
       padding: var(--spacing-m) var(--spacing-l);
     }
     .toggleItem.draftToggle {
@@ -65,27 +63,72 @@
       box-shadow: none;
       padding-left: var(--spacing-m);
     }
+    .header .categoryRadio {
+      height: 18px;
+      width: 18px;
+    }
+    .header label {
+      padding-left: 8px;
+      margin-right: 16px;
+    }
   </style>
   <template is="dom-if" if="[[!hideToggleButtons]]">
     <div class="header">
-      <div class="toggleItem">
-        <paper-toggle-button
-          id="unresolvedToggle"
-          checked="{{!unresolvedOnly}}"
-          on-tap="_onTapUnresolvedToggle"
-          >All comments</paper-toggle-button
+      <template is="dom-if" if="[[!_isNewChangeSummaryUiEnabled]]">
+        <div class="toggleItem">
+          <paper-toggle-button
+            id="unresolvedToggle"
+            checked="{{!unresolvedOnly}}"
+            on-tap="_onTapUnresolvedToggle"
+            >All comments</paper-toggle-button
+          >
+        </div>
+        <div
+          class$="toggleItem draftToggle [[_computeShowDraftToggle(loggedIn)]]"
         >
-      </div>
-      <div
-        class$="toggleItem draftToggle [[_computeShowDraftToggle(loggedIn)]]"
-      >
-        <paper-toggle-button
-          id="draftToggle"
-          checked="{{_draftsOnly}}"
-          on-tap="_onTapUnresolvedToggle"
-          >Comments with drafts</paper-toggle-button
-        >
-      </div>
+          <paper-toggle-button
+            id="draftToggle"
+            checked="{{_draftsOnly}}"
+            on-tap="_onTapUnresolvedToggle"
+            >Comments with drafts</paper-toggle-button
+          >
+        </div>
+      </template>
+      <template is="dom-if" if="[[_isNewChangeSummaryUiEnabled]]">
+          <input
+            class="categoryRadio"
+            id="unresolvedRadio"
+            name="filterComments"
+            type="radio"
+            on-click="_handleOnlyUnresolved"
+            checked="[[unresolvedOnly]]"
+          />
+          <label for="unresolvedRadio">
+            Unresolved ([[_countUnresolved(threads)]])
+          </label>
+          <input
+            class="categoryRadio"
+            id="draftsRadio"
+            name="filterComments"
+            type="radio"
+            on-click="_handleOnlyDrafts"
+            checked="[[_draftsOnly]]"
+          />
+          <label for="draftsRadio">
+            Drafts ([[_countDrafts(threads)]])
+          </label>
+          <input
+            class="categoryRadio"
+            id="allRadio"
+            name="filterComments"
+            type="radio"
+            on-click="_handleAllComments"
+            checked="[[_showAllComments(_draftsOnly, unresolvedOnly)]]"
+          />
+          <label for="all">
+            All ([[_countAllThreads(threads)]])
+          </label>
+      </template>
     </div>
   </template>
   <div id="threads">
@@ -106,7 +149,7 @@
               link
               on-click="_handleResolvedCommentsMessageClick">
                 [[_computeResolvedCommentsMessage(threads, _displayedThreads,
-                unresolvedOnly)]]
+                unresolvedOnly, onlyShowRobotCommentsWithHumanReply)]]
             </gr-button>
           </template>
         </span>
@@ -127,9 +170,11 @@
       </template>
       <gr-comment-thread
         show-file-path=""
+        show-ported-comment="[[thread.ported]]"
+        show-comment-context="[[showCommentContext]]"
         change-num="[[changeNum]]"
         comments="[[thread.comments]]"
-        comment-side="[[thread.commentSide]]"
+        diff-side="[[thread.diffSide]]"
         show-file-name="[[_isFirstThreadWithFileName(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
         project-name="[[change.project]]"
         is-on-parent="[[_isOnParent(thread.commentSide)]]"
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
index efc072f..622e1a2 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
@@ -43,7 +43,7 @@
       {
         comments: [
           {
-            __path: '/COMMIT_MSG',
+            path: '/COMMIT_MSG',
             author: {
               _account_id: 1000000,
               name: 'user',
@@ -79,7 +79,7 @@
       {
         comments: [
           {
-            __path: 'test.txt',
+            path: 'test.txt',
             author: {
               _account_id: 1000000,
               name: 'user',
@@ -102,7 +102,7 @@
       {
         comments: [
           {
-            __path: '/COMMIT_MSG',
+            path: '/COMMIT_MSG',
             author: {
               _account_id: 1000000,
               name: 'user',
@@ -123,7 +123,7 @@
       {
         comments: [
           {
-            __path: '/COMMIT_MSG',
+            path: '/COMMIT_MSG',
             author: {
               _account_id: 1000000,
               name: 'user',
@@ -201,7 +201,7 @@
       {
         comments: [
           {
-            __path: '/COMMIT_MSG',
+            path: '/COMMIT_MSG',
             author: {
               _account_id: 1000000,
               name: 'user',
@@ -225,7 +225,7 @@
       {
         comments: [
           {
-            __path: '/COMMIT_MSG',
+            path: '/COMMIT_MSG',
             author: {
               _account_id: 1000000,
               name: 'user',
@@ -240,7 +240,7 @@
             robot_id: 'rc2',
           },
           {
-            __path: '/COMMIT_MSG',
+            path: '/COMMIT_MSG',
             author: {
               _account_id: 1000000,
               name: 'user',
@@ -568,6 +568,7 @@
     modifiedThreads[5].comments.push({
       ...modifiedThreads[5].comments[0],
       __editing: true,
+      __draft: true,
     });
     element.threads = modifiedThreads;
     MockInteractions.tap(element.shadowRoot.querySelector('#draftToggle'));
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.ts b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.ts
index cab17dd..d52da80 100644
--- a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.ts
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 import '../../shared/gr-dialog/gr-dialog';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-shell-command/gr-shell-command';
 import '../../../styles/shared-styles';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
@@ -24,7 +23,7 @@
 import {htmlTemplate} from './gr-upload-help-dialog_html';
 import {customElement, property} from '@polymer/decorators';
 import {RevisionInfo} from '../../../types/common';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {appContext} from '../../../services/app-context';
 
 const COMMIT_COMMAND = 'git add . && git commit --amend --no-edit';
 const PUSH_COMMAND_PREFIX = 'git push origin HEAD:refs/for/';
@@ -32,12 +31,6 @@
 // Command names correspond to download plugin definitions.
 const PREFERRED_FETCH_COMMAND_ORDER = ['checkout', 'cherry pick', 'pull'];
 
-export interface GrUploadHelpDialog {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
-
 @customElement('gr-upload-help-dialog')
 export class GrUploadHelpDialog extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
@@ -73,13 +66,17 @@
   @property({type: String, computed: '_computePushCommand(targetBranch)'})
   _pushCommand?: string;
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
   attached() {
     super.attached();
-    this.$.restAPI
+    this.restApiService
       .getLoggedIn()
       .then(loggedIn =>
-        loggedIn ? this.$.restAPI.getPreferences() : Promise.resolve(undefined)
+        loggedIn
+          ? this.restApiService.getPreferences()
+          : Promise.resolve(undefined)
       )
       .then(prefs => {
         if (prefs) {
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_html.ts
index 1ee3a3a..d44cbb0 100644
--- a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_html.ts
@@ -66,5 +66,4 @@
       </ol>
     </div>
   </gr-dialog>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
new file mode 100644
index 0000000..ef2430c
--- /dev/null
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -0,0 +1,514 @@
+/**
+ * @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 {
+  css,
+  customElement,
+  internalProperty,
+  property,
+  PropertyValues,
+  query,
+} from 'lit-element';
+import {GrLitElement} from '../lit/gr-lit-element';
+import {
+  Category,
+  CheckRun,
+  Link,
+  LinkIcon,
+  RunStatus,
+  Tag,
+} from '../../api/checks';
+import {sharedStyles} from '../../styles/shared-styles';
+import {RunResult} from '../../services/checks/checks-model';
+import {
+  hasCompleted,
+  hasCompletedWithoutResults,
+  iconForCategory,
+  isRunning,
+} from '../../services/checks/checks-util';
+import {assertIsDefined} from '../../utils/common-util';
+import {whenVisible} from '../../utils/dom-util';
+import {durationString} from '../../utils/date-util';
+
+@customElement('gr-result-row')
+class GrResultRow extends GrLitElement {
+  @property()
+  result?: RunResult;
+
+  @property()
+  isExpanded = false;
+
+  @property({type: Boolean, reflect: true})
+  isExpandable = false;
+
+  @property()
+  shouldRender = false;
+
+  static get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: contents;
+        }
+        :host([isexpandable]) {
+          cursor: pointer;
+        }
+        tr {
+          border-top: 1px solid var(--border-color);
+        }
+        iron-icon.launch {
+          color: var(--link-color);
+          margin-right: var(--spacing-s);
+        }
+        td.iconCol {
+          padding-left: var(--spacing-l);
+          padding-right: var(--spacing-m);
+        }
+        .iconCol div {
+          width: 20px;
+        }
+        .nameCol div {
+          width: 165px;
+          overflow: hidden;
+          text-overflow: ellipsis;
+        }
+        .summaryCol {
+          /* Forces this column to get the remaining space that is left over by
+             the other columns. */
+          width: 99%;
+        }
+        .expanderCol div {
+          width: 20px;
+        }
+        td {
+          white-space: nowrap;
+          padding: var(--spacing-s);
+        }
+        td .summary-cell {
+          display: flex;
+          max-width: calc(100vw - 700px);
+        }
+        td .summary-cell .summary {
+          font-weight: var(--font-weight-bold);
+          flex-shrink: 1;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          margin-right: var(--spacing-s);
+        }
+        td .summary-cell .message {
+          flex-grow: 1;
+          /* Looks a bit stupid, but the idea is that .message shrinks first,
+             and only when that has shrunken to 0, then .summary should also
+             start shrinking (substantially). */
+          flex-shrink: 1000000;
+          overflow: hidden;
+          text-overflow: ellipsis;
+        }
+        td .summary-cell .tags .tag {
+          color: var(--deemphasized-text-color);
+          display: inline-block;
+          border-radius: 20px;
+          background-color: var(--tag-background);
+          padding: 0 var(--spacing-m);
+          margin-left: var(--spacing-s);
+        }
+        td .summary-cell .label {
+          color: var(--deemphasized-text-color);
+          display: inline-block;
+          border-radius: 20px;
+          background-color: var(--label-background);
+          padding: 0 var(--spacing-m);
+          margin-left: var(--spacing-s);
+        }
+      `,
+    ];
+  }
+
+  update(changedProperties: PropertyValues) {
+    if (changedProperties.has('result')) {
+      this.isExpandable = !!this.result?.summary && !!this.result?.message;
+    }
+    super.update(changedProperties);
+  }
+
+  firstUpdated() {
+    const loading = this.shadowRoot?.querySelector('.container');
+    assertIsDefined(loading, '"Loading" element');
+    whenVisible(loading, () => this.setAttribute('shouldRender', 'true'), 200);
+  }
+
+  render() {
+    if (!this.result) return '';
+    if (!this.shouldRender) {
+      return html`
+        <tr class="container">
+          <td class="iconCol"></td>
+          <td class="nameCol">
+            <div><span class="loading">Loading...</span></div>
+          </td>
+          <td class="summaryCol"></td>
+          <td class="expanderCol"></td>
+        </tr>
+      `;
+    }
+    return html`
+      <tr class="container" @click="${this.toggleExpanded}">
+        <td class="iconCol">
+          <div>${this.renderIcon()}</div>
+        </td>
+        <td class="nameCol">
+          <div><span>${this.result.checkName}</span></div>
+        </td>
+        <td class="summaryCol">
+          <div class="summary-cell">
+            ${(this.result.links?.slice(0, 5) ?? []).map(this.renderLink)}
+            ${this.renderSummary(this.result.summary)}
+            <div class="message">
+              ${this.isExpanded ? '' : this.result.message}
+            </div>
+            <div class="tags">
+              ${(this.result.tags ?? []).map(t => this.renderTag(t))}
+            </div>
+            ${this.renderLabel()}
+          </div>
+          <gr-result-expanded
+            .result="${this.result}"
+            ?hidden="${!this.isExpanded}"
+          ></gr-result-expanded>
+        </td>
+        <td class="expanderCol">
+          <div
+            class="show-hide"
+            role="switch"
+            tabindex="0"
+            ?hidden="${!this.isExpandable}"
+            ?aria-checked="${this.isExpanded}"
+            aria-label="${this.isExpanded
+              ? 'Collapse result row'
+              : 'Expand result row'}"
+            @keydown="${this.toggleExpanded}"
+          >
+            <iron-icon
+              icon="${this.isExpanded
+                ? 'gr-icons:expand-less'
+                : 'gr-icons:expand-more'}"
+            ></iron-icon>
+          </div>
+        </td>
+      </tr>
+    `;
+  }
+
+  private toggleExpanded() {
+    if (!this.isExpandable) return;
+    this.isExpanded = !this.isExpanded;
+  }
+
+  renderSummary(text?: string) {
+    if (!text) return;
+    return html`
+      <!-- The &nbsp; is for being able to shrink a tiny amount without
+       the text itself getting shrunk with an ellipsis. -->
+      <div class="summary">${text}&nbsp;</div>
+    `;
+  }
+
+  renderLink(link: Link) {
+    return html`
+      <a href="${link.url}" target="_blank">
+        <iron-icon
+          aria-label="external link to details"
+          class="launch"
+          icon="gr-icons:launch"
+        ></iron-icon>
+      </a>
+    `;
+  }
+
+  renderIcon() {
+    if (this.result?.status !== RunStatus.RUNNING) return;
+    return html`<iron-icon icon="gr-icons:timelapse"></iron-icon>`;
+  }
+
+  renderLabel() {
+    const label = this.result?.labelName;
+    if (!label) return;
+    return html`<div class="label">${label}</div>`;
+  }
+
+  renderTag(tag: Tag) {
+    return html`<div class="tag">${tag.name}</div>`;
+  }
+}
+
+@customElement('gr-result-expanded')
+class GrResultExpanded extends GrLitElement {
+  @property()
+  result?: RunResult;
+
+  static get styles() {
+    return [
+      sharedStyles,
+      css`
+        .message {
+          padding: var(--spacing-m) var(--spacing-m) var(--spacing-m) 0;
+        }
+      `,
+    ];
+  }
+
+  render() {
+    if (!this.result) return '';
+    return html`
+      <gr-endpoint-decorator name="check-result-expanded">
+        <gr-endpoint-param
+          name="run"
+          value="${this.result}"
+        ></gr-endpoint-param>
+        <gr-endpoint-param
+          name="result"
+          value="${this.result}"
+        ></gr-endpoint-param>
+        <div class="message">
+          ${this.result.message}
+        </div>
+      </gr-endpoint-decorator>
+    `;
+  }
+}
+
+@customElement('gr-checks-results')
+export class GrChecksResults extends GrLitElement {
+  @query('#filterInput')
+  filterInput?: HTMLInputElement;
+
+  @internalProperty()
+  filterRegExp = new RegExp('');
+
+  @property()
+  runs: CheckRun[] = [];
+
+  private isSectionExpanded = new Map<Category | 'SUCCESS', boolean>();
+
+  static get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+          padding: var(--spacing-xl);
+        }
+        input#filterInput {
+          margin-top: var(--spacing-s);
+          padding: var(--spacing-s) var(--spacing-m);
+          min-width: 400px;
+        }
+        .categoryHeader {
+          margin-top: var(--spacing-l);
+          margin-left: var(--spacing-l);
+          text-transform: capitalize;
+          cursor: default;
+        }
+        .categoryHeader .expandIcon {
+          width: var(--line-height-h3);
+          height: var(--line-height-h3);
+          margin-right: var(--spacing-s);
+        }
+        .categoryHeader .statusIcon {
+          position: relative;
+          top: 2px;
+        }
+        .categoryHeader .statusIcon.error {
+          color: var(--error-foreground);
+        }
+        .categoryHeader .statusIcon.warning {
+          color: var(--warning-foreground);
+        }
+        .categoryHeader .statusIcon.info {
+          color: var(--info-foreground);
+        }
+        .categoryHeader .statusIcon.success {
+          color: var(--success-foreground);
+        }
+        .collapsed table {
+          display: none;
+        }
+        .collapsed {
+          border-bottom: 1px solid var(--border-color);
+          padding-bottom: var(--spacing-m);
+        }
+        .noCompleted {
+          margin-top: var(--spacing-l);
+        }
+        table.resultsTable {
+          width: 100%;
+          max-width: 1280px;
+          margin-top: var(--spacing-m);
+          background-color: var(--background-color-primary);
+          box-shadow: var(--elevation-level-1);
+        }
+        tr.headerRow th {
+          text-align: left;
+          font-weight: var(--font-weight-bold);
+          padding: var(--spacing-s);
+        }
+      `,
+    ];
+  }
+
+  render() {
+    return html`
+      <div><h2 class="heading-2">Results</h2></div>
+      ${this.renderFilter()} ${this.renderNoCompleted()}
+      ${this.renderSection(Category.ERROR)}
+      ${this.renderSection(Category.WARNING)}
+      ${this.renderSection(Category.INFO)} ${this.renderSection('SUCCESS')}
+    `;
+  }
+
+  renderFilter() {
+    if (this.runs.length === 0) return;
+    return html`
+      <input
+        id="filterInput"
+        type="text"
+        placeholder="Filter results by regular expression"
+        @input="${this.onInput}"
+      />
+    `;
+  }
+
+  onInput() {
+    assertIsDefined(this.filterInput, 'filter <input> element');
+    this.filterRegExp = new RegExp(this.filterInput.value, 'i');
+  }
+
+  renderNoCompleted() {
+    if (this.runs.some(hasCompleted)) return;
+    let text = 'No results';
+    if (this.runs.some(isRunning)) {
+      text = 'Checks are running ...';
+    }
+    return html`<div class="noCompleted">${text}</div>`;
+  }
+
+  renderSection(category: Category | 'SUCCESS') {
+    const catString = category.toString().toLowerCase();
+    let runs = this.runs;
+    if (category === 'SUCCESS') {
+      runs = runs
+        .filter(hasCompletedWithoutResults)
+        .filter(r => this.filterRegExp.test(r.checkName));
+    } else {
+      runs = runs.filter(r =>
+        (r.results ?? []).some(res => res.category === category)
+      );
+    }
+    if (runs.length === 0) return;
+    const expanded = this.isSectionExpanded.get(category) ?? true;
+    const expandedClass = expanded ? 'expanded' : 'collapsed';
+    const icon = expanded ? 'gr-icons:expand-more' : 'gr-icons:expand-less';
+    return html`
+      <div class="${expandedClass}">
+        <h3
+          class="categoryHeader heading-3"
+          @click="${() => this.toggleExpanded(category)}"
+        >
+          <iron-icon class="expandIcon" icon="${icon}"></iron-icon>
+          <iron-icon
+            icon="gr-icons:${iconForCategory(category)}"
+            class="statusIcon ${catString}"
+          ></iron-icon>
+          ${catString}
+        </h3>
+        <table class="resultsTable">
+          <thead>
+            <tr class="headerRow">
+              <th class="iconCol"></th>
+              <th class="nameCol">Run</th>
+              <th class="summaryCol">Summary</th>
+              <th class="expanderCol"></th>
+            </tr>
+          </thead>
+          <tbody>
+            ${runs.map(run =>
+              category === 'SUCCESS'
+                ? this.renderSuccessfulRun(run)
+                : this.renderRun(category, run)
+            )}
+          </tbody>
+        </table>
+      </div>
+    `;
+  }
+
+  toggleExpanded(category: Category | 'SUCCESS') {
+    const expanded = this.isSectionExpanded.get(category) ?? true;
+    this.isSectionExpanded.set(category, !expanded);
+    this.requestUpdate();
+  }
+
+  renderRun(category: Category, run: CheckRun) {
+    return html`${run.results
+      ?.filter(result => result.category === category)
+      .filter(
+        result =>
+          this.filterRegExp.test(run.checkName) ||
+          this.filterRegExp.test(result.summary)
+      )
+      .map(
+        result =>
+          html`<gr-result-row .result="${{...run, ...result}}"></gr-result-row>`
+      )}`;
+  }
+
+  renderSuccessfulRun(run: CheckRun) {
+    const adaptedRun: RunResult = {
+      category: Category.INFO, // will not be used, but is required
+      summary: run.statusDescription ?? '',
+      ...run,
+    };
+    if (!run.statusDescription) {
+      const start = run.scheduledTimestamp ?? run.startedTimestamp;
+      const end = run.finishedTimestamp;
+      let duration = '';
+      if (start && end) {
+        duration = ` in ${durationString(start, end, true)}`;
+      }
+      adaptedRun.message = `Completed without results${duration}.`;
+    }
+    if (run.statusLink) {
+      adaptedRun.links = [
+        {
+          url: run.statusLink,
+          primary: true,
+          icon: LinkIcon.EXTERNAL,
+        },
+      ];
+    }
+    return html`<gr-result-row .result="${adaptedRun}"></gr-result-row>`;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-result-row': GrResultRow;
+    'gr-result-expanded': GrResultExpanded;
+    'gr-checks-results': GrChecksResults;
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts b/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
similarity index 68%
copy from polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
copy to polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
index ac59f4f..babfd42 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
@@ -14,8 +14,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
 
-export const htmlTemplate = html`
-  <gr-lib-loader id="libLoader"></gr-lib-loader>
-`;
+import '../../test/common-test-setup-karma';
+import {GrChecksResults} from './gr-checks-results';
+
+suite('gr-checks-results test', () => {
+  test('is defined', () => {
+    const el = document.createElement('gr-checks-results');
+    assert.instanceOf(el, GrChecksResults);
+  });
+});
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
new file mode 100644
index 0000000..31f17724
--- /dev/null
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -0,0 +1,426 @@
+/**
+ * @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, nothing} from 'lit-html';
+import {classMap} from 'lit-html/directives/class-map';
+import {
+  css,
+  customElement,
+  internalProperty,
+  property,
+  query,
+} from 'lit-element';
+import {GrLitElement} from '../lit/gr-lit-element';
+import {Action, CheckRun, RunStatus} from '../../api/checks';
+import {sharedStyles} from '../../styles/shared-styles';
+import {
+  compareByWorstCategory,
+  fireActionTriggered,
+  iconForCategory,
+  iconForRun,
+  primaryRunAction,
+  worstCategory,
+} from '../../services/checks/checks-util';
+import {
+  allRuns$,
+  fakeRun0,
+  fakeRun1,
+  fakeRun2,
+  fakeRun3,
+  fakeRun4,
+  updateStateSetResults,
+} from '../../services/checks/checks-model';
+import {assertIsDefined} from '../../utils/common-util';
+import {whenVisible} from '../../utils/dom-util';
+
+export interface RunSelectedEventDetail {
+  checkName: string;
+}
+
+export type RunSelectedEvent = CustomEvent<RunSelectedEventDetail>;
+
+declare global {
+  interface HTMLElementEventMap {
+    'run-selected': RunSelectedEvent;
+  }
+}
+
+function fireRunSelected(target: EventTarget, checkName: string) {
+  target.dispatchEvent(
+    new CustomEvent('run-selected', {
+      detail: {checkName},
+      composed: true,
+      bubbles: true,
+    })
+  );
+}
+
+@customElement('gr-checks-run')
+export class GrChecksRun extends GrLitElement {
+  static get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+          --thick-border: 6px;
+        }
+        .chip {
+          display: flex;
+          justify-content: space-between;
+          border: 1px solid var(--border-color);
+          border-radius: var(--border-radius);
+          padding: var(--spacing-s) var(--spacing-m);
+          margin-top: var(--spacing-s);
+          cursor: pointer;
+        }
+        .left {
+          overflow: hidden;
+          white-space: nowrap;
+          text-overflow: ellipsis;
+        }
+        .name {
+          font-weight: var(--font-weight-bold);
+        }
+        .chip.error {
+          border-left: var(--thick-border) solid var(--error-foreground);
+        }
+        .chip.warning {
+          border-left: var(--thick-border) solid var(--warning-foreground);
+        }
+        .chip.info-outline {
+          border-left: var(--thick-border) solid var(--info-foreground);
+        }
+        .chip.check-circle-outline {
+          border-left: var(--thick-border) solid var(--success-foreground);
+        }
+        .chip.timelapse {
+          border-left: var(--thick-border) solid var(--border-color);
+        }
+        .chip.placeholder {
+          border-left: var(--thick-border) solid var(--border-color);
+        }
+        .chip.placeholder iron-icon {
+          display: none;
+        }
+        iron-icon.error {
+          color: var(--error-foreground);
+        }
+        iron-icon.warning {
+          color: var(--warning-foreground);
+        }
+        iron-icon.info-outline {
+          color: var(--info-foreground);
+        }
+        iron-icon.check-circle-outline {
+          color: var(--success-foreground);
+        }
+        /* Additional 'div' for increased specificity. */
+        div.chip.selected {
+          border: 1px solid var(--selected-foreground);
+          background-color: var(--selected-background);
+          padding-left: calc(var(--spacing-m) + var(--thick-border) - 1px);
+        }
+        div.chip.deselected {
+          border: 1px solid var(--gray-foreground);
+          background-color: transparent;
+          padding-left: calc(var(--spacing-m) + var(--thick-border) - 1px);
+        }
+        div.chip.selected iron-icon {
+          color: var(--selected-foreground);
+        }
+        div.chip.deselected iron-icon {
+          color: var(--gray-foreground);
+        }
+        .chip.selected gr-button.action,
+        .chip.deselected gr-button.action {
+          display: none;
+        }
+        gr-button.action {
+          --padding: var(--spacing-xs) var(--spacing-m);
+          /* The button should fit into the 20px line-height. The negative
+             margin provides the extra space needed for the vertical padding.
+             Alternatively we could have set the vertical padding to 0, but
+             that would not have been a nice click target. */
+          margin: calc(0px - var(--spacing-xs));
+        }
+      `,
+    ];
+  }
+
+  @query('.chip')
+  chipElement?: HTMLElement;
+
+  @property()
+  run!: CheckRun;
+
+  @property()
+  selected = false;
+
+  @property()
+  deselected = false;
+
+  @property()
+  shouldRender = false;
+
+  firstUpdated() {
+    assertIsDefined(this.chipElement, 'chip element');
+    whenVisible(
+      this.chipElement,
+      () => this.setAttribute('shouldRender', 'true'),
+      200
+    );
+  }
+
+  render() {
+    if (!this.shouldRender) return html`<div class="chip">Loading ...</div>`;
+
+    const icon = this.selected ? 'filter' : iconForRun(this.run);
+    const classes = {
+      chip: true,
+      [icon]: true,
+      selected: this.selected,
+      deselected: this.deselected,
+    };
+    const action = primaryRunAction(this.run);
+
+    return html`
+      <div @click="${this.handleChipClick}" class="${classMap(classes)}">
+        <div class="left">
+          <iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
+          ${this.renderAdditionalIcon()}
+          <span class="name">${this.run.checkName}</span>
+        </div>
+        <div class="right">
+          ${action
+            ? html`<gr-button
+                class="action"
+                link
+                @click="${(e: MouseEvent) => this.handleAction(e, action)}"
+                >${action.name}</gr-button
+              >`
+            : ''}
+        </div>
+      </div>
+    `;
+  }
+
+  /**
+   * For RUNNING we also want to render an icon representing the worst result
+   * that has been reported until now - if there are any results already.
+   */
+  renderAdditionalIcon() {
+    if (this.run.status !== RunStatus.RUNNING) return nothing;
+    const category = worstCategory(this.run);
+    if (!category) return nothing;
+    const icon = iconForCategory(category);
+    return html`
+      <iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
+    `;
+  }
+
+  private handleChipClick(e: MouseEvent) {
+    e.stopPropagation();
+    e.preventDefault();
+    fireRunSelected(this, this.run.checkName);
+  }
+
+  private handleAction(e: MouseEvent, action: Action) {
+    e.stopPropagation();
+    e.preventDefault();
+    fireActionTriggered(this, action, this.run);
+  }
+}
+
+@customElement('gr-checks-runs')
+export class GrChecksRuns extends GrLitElement {
+  @query('#filterInput')
+  filterInput?: HTMLInputElement;
+
+  @internalProperty()
+  filterRegExp = new RegExp('');
+
+  @property()
+  runs: CheckRun[] = [];
+
+  @property()
+  selectedRuns: string[] = [];
+
+  private isSectionExpanded = new Map<RunStatus, boolean>();
+
+  constructor() {
+    super();
+    this.subscribe('runs', allRuns$);
+  }
+
+  static get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+          padding: var(--spacing-xl);
+        }
+        .expandIcon {
+          width: var(--line-height-h3);
+          height: var(--line-height-h3);
+        }
+        .sectionHeader {
+          padding-top: var(--spacing-l);
+          text-transform: capitalize;
+          cursor: default;
+        }
+        .sectionHeader h3 {
+          display: inline-block;
+        }
+        .collapsed .sectionRuns {
+          display: none;
+        }
+        .collapsed {
+          border-bottom: 1px solid var(--border-color);
+          padding-bottom: var(--spacing-m);
+        }
+        input#filterInput {
+          margin-top: var(--spacing-s);
+          padding: var(--spacing-s) var(--spacing-m);
+          width: 100%;
+        }
+        .testing {
+          margin-top: var(--spacing-xxl);
+          color: var(--deemphasized-text-color);
+        }
+        .testing gr-button {
+          min-width: 25px;
+        }
+        .testing * {
+          visibility: hidden;
+        }
+        .testing:hover * {
+          visibility: visible;
+        }
+      `,
+    ];
+  }
+
+  render() {
+    return html`
+      <h2 class="heading-2">Runs</h2>
+      <input
+        id="filterInput"
+        type="text"
+        placeholder="Filter runs by regular expression"
+        @input="${this.onInput}"
+      />
+      ${this.renderSection(RunStatus.COMPLETED)}
+      ${this.renderSection(RunStatus.RUNNING)}
+      ${this.renderSection(RunStatus.RUNNABLE)}
+      <div class="testing">
+        <div>Toggle fake runs by clicking buttons:</div>
+        <gr-button link @click="${this.none}">none</gr-button>
+        <gr-button link @click="${() => this.toggle('f0', fakeRun0)}"
+          >0</gr-button
+        >
+        <gr-button link @click="${() => this.toggle('f1', fakeRun1)}"
+          >1</gr-button
+        >
+        <gr-button link @click="${() => this.toggle('f2', fakeRun2)}"
+          >2</gr-button
+        >
+        <gr-button link @click="${() => this.toggle('f3', fakeRun3)}"
+          >3</gr-button
+        >
+        <gr-button link @click="${() => this.toggle('f4', fakeRun4)}"
+          >4</gr-button
+        >
+        <gr-button link @click="${this.all}">all</gr-button>
+      </div>
+    `;
+  }
+
+  onInput() {
+    assertIsDefined(this.filterInput, 'filter <input> element');
+    this.filterRegExp = new RegExp(this.filterInput.value, 'i');
+  }
+
+  none() {
+    updateStateSetResults('f0', []);
+    updateStateSetResults('f1', []);
+    updateStateSetResults('f2', []);
+    updateStateSetResults('f3', []);
+    updateStateSetResults('f4', []);
+  }
+
+  all() {
+    updateStateSetResults('f0', [fakeRun0]);
+    updateStateSetResults('f1', [fakeRun1]);
+    updateStateSetResults('f2', [fakeRun2]);
+    updateStateSetResults('f3', [fakeRun3]);
+    updateStateSetResults('f4', [fakeRun4]);
+  }
+
+  toggle(plugin: string, run: CheckRun) {
+    const newRuns = this.runs.includes(run) ? [] : [run];
+    updateStateSetResults(plugin, newRuns);
+  }
+
+  renderSection(status: RunStatus) {
+    const runs = this.runs
+      .filter(r => r.status === status)
+      .filter(r => this.filterRegExp.test(r.checkName))
+      .sort(compareByWorstCategory);
+    if (runs.length === 0) return;
+    const expanded = this.isSectionExpanded.get(status) ?? true;
+    const expandedClass = expanded ? 'expanded' : 'collapsed';
+    const icon = expanded ? 'gr-icons:expand-more' : 'gr-icons:expand-less';
+    return html`
+      <div class="${status.toLowerCase()} ${expandedClass}">
+        <div
+          class="sectionHeader"
+          @click="${() => this.toggleExpanded(status)}"
+        >
+          <iron-icon class="expandIcon" icon="${icon}"></iron-icon>
+          <h3 class="heading-3">${status.toLowerCase()}</h3>
+        </div>
+        <div class="sectionRuns">
+          ${runs.map(run => this.renderRun(run))}
+        </div>
+      </div>
+    `;
+  }
+
+  toggleExpanded(status: RunStatus) {
+    const expanded = this.isSectionExpanded.get(status) ?? true;
+    this.isSectionExpanded.set(status, !expanded);
+    this.requestUpdate();
+  }
+
+  renderRun(run: CheckRun) {
+    const selected = this.selectedRuns.includes(run.checkName);
+    const deselected = !selected && this.selectedRuns.length > 0;
+    return html`<gr-checks-run
+      .run="${run}"
+      .selected="${selected}"
+      .deselected="${deselected}"
+    ></gr-checks-run>`;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-checks-run': GrChecksRun;
+    'gr-checks-runs': GrChecksRuns;
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
similarity index 69%
copy from polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
copy to polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
index ac59f4f..4d54200 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
@@ -14,8 +14,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
 
-export const htmlTemplate = html`
-  <gr-lib-loader id="libLoader"></gr-lib-loader>
-`;
+import '../../test/common-test-setup-karma';
+import {GrChecksRuns} from './gr-checks-runs';
+
+suite('gr-checks-runs test', () => {
+  test('is defined', () => {
+    const el = document.createElement('gr-checks-runs');
+    assert.instanceOf(el, GrChecksRuns);
+  });
+});
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..9376a03
--- /dev/null
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -0,0 +1,277 @@
+/**
+ * @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 {
+  css,
+  customElement,
+  internalProperty,
+  property,
+  PropertyValues,
+} from 'lit-element';
+import {GrLitElement} from '../lit/gr-lit-element';
+import {Action, CheckResult, CheckRun} from '../../api/checks';
+import {
+  allActions$,
+  allResults$,
+  allRuns$,
+  checksPatchsetNumber$,
+  someProvidersAreLoading$,
+} from '../../services/checks/checks-model';
+import './gr-checks-runs';
+import './gr-checks-results';
+import {sharedStyles} from '../../styles/shared-styles';
+import {changeNum$, latestPatchNum$} from '../../services/change/change-model';
+import {NumericChangeId, PatchSetNumber} from '../../types/common';
+import {
+  ActionTriggeredEvent,
+  fireActionTriggered,
+} from '../../services/checks/checks-util';
+import {
+  assertIsDefined,
+  check,
+  checkRequiredProperty,
+} from '../../utils/common-util';
+import {RunSelectedEvent} from './gr-checks-runs';
+import {ChecksTabState} from '../../types/events';
+import {fireAlert} from '../../utils/event-util';
+import {appContext} from '../../services/app-context';
+import {from, timer} from 'rxjs';
+import {takeUntil} from 'rxjs/operators';
+
+/**
+ * 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 {
+  @property()
+  runs: CheckRun[] = [];
+
+  results: CheckResult[] = [];
+
+  actions: Action[] = [];
+
+  @property()
+  tabState?: ChecksTabState;
+
+  @property()
+  checksPatchsetNumber: PatchSetNumber | undefined = undefined;
+
+  @property()
+  latestPatchsetNumber: PatchSetNumber | undefined = undefined;
+
+  @property()
+  changeNum: NumericChangeId | undefined = undefined;
+
+  @property()
+  someProvidersAreLoading = false;
+
+  @internalProperty()
+  selectedRuns: string[] = [];
+
+  private readonly checksService = appContext.checksService;
+
+  constructor() {
+    super();
+    this.subscribe('runs', allRuns$);
+    this.subscribe('actions', allActions$);
+    this.subscribe('results', allResults$);
+    this.subscribe('checksPatchsetNumber', checksPatchsetNumber$);
+    this.subscribe('latestPatchsetNumber', latestPatchNum$);
+    this.subscribe('changeNum', changeNum$);
+    this.subscribe('someProvidersAreLoading', someProvidersAreLoading$);
+
+    this.addEventListener('action-triggered', (e: ActionTriggeredEvent) =>
+      this.handleActionTriggered(e.detail.action, e.detail.run)
+    );
+  }
+
+  static get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+        }
+        .header {
+          display: flex;
+          justify-content: space-between;
+          padding: var(--spacing-m) var(--spacing-l);
+          border-bottom: 1px solid var(--border-color);
+        }
+        .action {
+          margin-left: var(--spacing-m);
+        }
+        .container {
+          display: flex;
+        }
+        .runs {
+          min-width: 300px;
+          min-height: 400px;
+          border-right: 1px solid var(--border-color);
+        }
+        .results {
+          background-color: var(--background-color-secondary);
+          flex-grow: 1;
+        }
+      `,
+    ];
+  }
+
+  render() {
+    const filteredRuns = this.runs.filter(
+      r =>
+        this.selectedRuns.length === 0 ||
+        this.selectedRuns.includes(r.checkName)
+    );
+    return html`
+      <div class="header">
+        <div class="left">
+          <gr-dropdown-list
+            value="${this.checksPatchsetNumber}"
+            .items="${this.createPatchsetDropdownItems()}"
+            @value-change="${this.onPatchsetSelected}"
+          ></gr-dropdown-list>
+          <span ?hidden="${!this.someProvidersAreLoading}">Loading...</span>
+        </div>
+        <div class="right">
+          ${this.actions.map(this.renderAction)}
+        </div>
+      </div>
+      <div class="container">
+        <gr-checks-runs
+          class="runs"
+          .runs="${this.runs}"
+          .selectedRuns="${this.selectedRuns}"
+          @run-selected="${this.handleRunSelected}"
+        ></gr-checks-runs>
+        <gr-checks-results
+          class="results"
+          .runs="${filteredRuns}"
+        ></gr-checks-results>
+      </div>
+    `;
+  }
+
+  private onPatchsetSelected(e: CustomEvent<{value: string}>) {
+    const patchset = Number(e.detail.value);
+    check(!isNaN(patchset), 'selected patchset must be a number');
+    this.checksService.setPatchset(patchset as PatchSetNumber);
+  }
+
+  private createPatchsetDropdownItems() {
+    return Array.from(Array(this.latestPatchsetNumber), (_, i) => {
+      assertIsDefined(this.latestPatchsetNumber, 'latestPatchsetNumber');
+      const index = this.latestPatchsetNumber - i;
+      const postfix = index === this.latestPatchsetNumber ? ' (latest)' : '';
+      return {
+        value: `${index}`,
+        text: `Patchset ${index}${postfix}`,
+      };
+    });
+  }
+
+  protected updated(changedProperties: PropertyValues) {
+    super.updated(changedProperties);
+    if (changedProperties.has('tabState')) {
+      const check = this.tabState?.checkName;
+      if (check) {
+        this.selectedRuns = [check];
+      }
+    }
+  }
+
+  renderAction(action: Action) {
+    return html`<gr-checks-top-level-action
+      .action="${action}"
+    ></gr-checks-top-level-action>`;
+  }
+
+  handleActionTriggered(action: Action, run?: CheckRun) {
+    if (!this.changeNum) return;
+    if (!this.checksPatchsetNumber) return;
+    const promise = action.callback(
+      this.changeNum,
+      this.checksPatchsetNumber,
+      run?.attempt,
+      run?.externalId,
+      run?.checkName,
+      action.name
+    );
+    // Plugins *should* return a promise, but you never know ...
+    if (promise?.then) {
+      const prefix = `Triggering action '${action.name}'`;
+      fireAlert(this, `${prefix} ...`);
+      from(promise)
+        // If the action takes longer than 5 seconds, then most likely the
+        // user is either not interested or the result not relevant anymore.
+        .pipe(takeUntil(timer(5000)))
+        .subscribe(result => {
+          if (result.errorMessage) {
+            fireAlert(this, `${prefix} failed with ${result.errorMessage}.`);
+          } else {
+            fireAlert(this, `${prefix} successful.`);
+            this.checksService.reloadForCheck(run?.checkName);
+          }
+        });
+    } else {
+      fireAlert(this, `Action '${action.name}' triggered.`);
+    }
+  }
+
+  handleRunSelected(e: RunSelectedEvent) {
+    this.toggleSelected(e.detail.checkName);
+  }
+
+  toggleSelected(checkName: string) {
+    if (this.selectedRuns.includes(checkName)) {
+      this.selectedRuns = this.selectedRuns.filter(r => r !== checkName);
+    } else {
+      this.selectedRuns = [...this.selectedRuns, checkName];
+    }
+  }
+}
+
+@customElement('gr-checks-top-level-action')
+export class GrChecksTopLevelAction extends GrLitElement {
+  @property()
+  action!: Action;
+
+  connectedCallback() {
+    super.connectedCallback();
+    checkRequiredProperty(this.action, 'action');
+  }
+
+  render() {
+    return html`
+      <gr-button link class="action" @click="${this.handleClick}"
+        >${this.action.name}</gr-button
+      >
+    `;
+  }
+
+  handleClick() {
+    fireActionTriggered(this, this.action);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-checks-tab': GrChecksTab;
+    'gr-checks-top-level-action': GrChecksTopLevelAction;
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts
similarity index 70%
copy from polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
copy to polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts
index ac59f4f..85183ed 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts
@@ -14,8 +14,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
 
-export const htmlTemplate = html`
-  <gr-lib-loader id="libLoader"></gr-lib-loader>
-`;
+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-account-dropdown/gr-account-dropdown.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
index ef0ced8..0361b9a 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
@@ -16,7 +16,6 @@
  */
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-dropdown/gr-dropdown';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/shared-styles';
 import '../../shared/gr-avatar/gr-avatar';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
@@ -26,7 +25,8 @@
 import {getUserName} from '../../../utils/display-name-util';
 import {customElement, property} from '@polymer/decorators';
 import {AccountInfo, ServerInfo} from '../../../types/common';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {appContext} from '../../../services/app-context';
+import {fireEvent} from '../../../utils/event-util';
 
 const INTERPOLATE_URL_PATTERN = /\${([\w]+)}/g;
 
@@ -36,12 +36,6 @@
   }
 }
 
-export interface GrAccountDropdown {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
-
 @customElement('gr-account-dropdown')
 export class GrAccountDropdown extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
@@ -71,12 +65,14 @@
   @property({type: String})
   _switchAccountUrl = '';
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
   attached() {
     super.attached();
     this._handleLocationChange();
     this.listen(window, 'location-change', '_handleLocationChange');
-    this.$.restAPI.getConfig().then(cfg => {
+    this.restApiService.getConfig().then(cfg => {
       this.config = cfg;
 
       if (cfg && cfg.auth && cfg.auth.switch_account_url) {
@@ -120,12 +116,7 @@
   }
 
   _handleShortcutsTap() {
-    this.dispatchEvent(
-      new CustomEvent('show-keyboard-shortcuts', {
-        bubbles: true,
-        composed: true,
-      })
-    );
+    fireEvent(this, 'show-keyboard-shortcuts');
   }
 
   _handleLocationChange() {
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.ts
index b67e1e8..0fa2f1e 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.ts
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.ts
@@ -49,5 +49,4 @@
       aria-label="Account avatar"
     ></gr-avatar>
   </gr-dropdown>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.js
index a8f206c..dc5d1b4 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.js
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-account-dropdown.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-account-dropdown');
 
@@ -24,9 +25,7 @@
   let element;
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-    });
+    stubRestApi('getConfig').returns(Promise.resolve({}));
     element = basicFixture.instantiate();
   });
 
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
index 7a56d1c..4a86005 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
@@ -19,8 +19,6 @@
 import '../gr-error-dialog/gr-error-dialog';
 import '../../shared/gr-alert/gr-alert';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
-import '../../shared/gr-js-api-interface/gr-js-api-interface';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
@@ -29,16 +27,18 @@
 import {appContext} from '../../../services/app-context';
 import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
 import {customElement, property} from '@polymer/decorators';
-import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
-import {AuthService} from '../../../services/gr-auth/gr-auth';
-import {EventEmitterService} from '../../../services/gr-event-interface/gr-event-interface';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrErrorDialog} from '../gr-error-dialog/gr-error-dialog';
 import {GrAlert} from '../../shared/gr-alert/gr-alert';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {FetchRequest} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 import {ErrorType, FixIronA11yAnnouncer} from '../../../types/types';
 import {AccountId} from '../../../types/common';
+import {EventType} from '../../../utils/event-util';
+import {
+  NetworkErrorEvent,
+  ServerErrorEvent,
+  ShowAlertEvent,
+} from '../../../types/events';
+import {windowLocationReload} from '../../../utils/dom-util';
 
 const HIDE_ALERT_TIMEOUT_MS = 5000;
 const CHECK_SIGN_IN_INTERVAL_MS = 60 * 1000;
@@ -71,9 +71,11 @@
     noInteractionOverlay: GrOverlay;
     errorDialog: GrErrorDialog;
     errorOverlay: GrOverlay;
-    restAPI: RestApiService & Element;
   };
 }
+
+const DEBOUNCER_CHECK_LOGGED_IN = 'checkLoggedIn';
+
 @customElement('gr-error-manager')
 export class GrErrorManager extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
@@ -107,29 +109,22 @@
   @property({type: String})
   loginUrl = '/login';
 
-  reporting: ReportingService;
+  private readonly reporting = appContext.reportingService;
 
-  _authService: AuthService;
+  private readonly _authService = appContext.authService;
 
-  eventEmitter: EventEmitterService;
+  private readonly eventEmitter = appContext.eventEmitter;
 
   _authErrorHandlerDeregistrationHook?: Function;
 
-  constructor() {
-    super();
-
-    this._authService = appContext.authService;
-
-    this.reporting = appContext.reportingService;
-    this.eventEmitter = appContext.eventEmitter;
-  }
+  private readonly restApiService = appContext.restApiService;
 
   /** @override */
   attached() {
     super.attached();
-    this.listen(document, 'server-error', '_handleServerError');
-    this.listen(document, 'network-error', '_handleNetworkError');
-    this.listen(document, 'show-alert', '_handleShowAlert');
+    this.listen(document, EventType.SERVER_ERROR, '_handleServerError');
+    this.listen(document, EventType.NETWORK_ERROR, '_handleNetworkError');
+    this.listen(document, EventType.SHOW_ALERT, '_handleShowAlert');
     this.listen(document, 'hide-alert', '_hideAlert');
     this.listen(document, 'show-error', '_handleShowErrorDialog');
     this.listen(document, 'visibilitychange', '_handleVisibilityChange');
@@ -149,13 +144,14 @@
   detached() {
     super.detached();
     this._clearHideAlertHandle();
-    this.unlisten(document, 'server-error', '_handleServerError');
-    this.unlisten(document, 'network-error', '_handleNetworkError');
-    this.unlisten(document, 'show-alert', '_handleShowAlert');
+    this.unlisten(document, EventType.SERVER_ERROR, '_handleServerError');
+    this.unlisten(document, EventType.NETWORK_ERROR, '_handleNetworkError');
+    this.unlisten(document, EventType.SHOW_ALERT, '_handleShowAlert');
     this.unlisten(document, 'hide-alert', '_hideAlert');
     this.unlisten(document, 'show-error', '_handleShowErrorDialog');
     this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
     this.unlisten(document, 'show-auth-required', '_handleAuthRequired');
+    this.cancelDebouncer(DEBOUNCER_CHECK_LOGGED_IN);
 
     if (this._authErrorHandlerDeregistrationHook) {
       this._authErrorHandlerDeregistrationHook();
@@ -179,9 +175,7 @@
     });
   }
 
-  _handleServerError(
-    e: CustomEvent<{response: Response; request: FetchRequest}>
-  ) {
+  _handleServerError(e: ServerErrorEvent) {
     const {request, response} = e.detail;
     response.text().then(errorText => {
       const url = request && (request.anonymizedUrl || request.url);
@@ -203,7 +197,7 @@
         // This indicates the auth token may no longer valid.
         // Re-check on auth
         this._authService.clearCache();
-        this.$.restAPI.getLoggedIn();
+        this.restApiService.getLoggedIn();
       } else if (!this._shouldSuppressError(errorText)) {
         const trace =
           response.headers && response.headers.get('X-Gerrit-Trace');
@@ -238,7 +232,7 @@
     url,
     trace,
   }: ErrorMsg) {
-    this.$.restAPI.getLoggedIn().then(isLoggedIn => {
+    this.restApiService.getLoggedIn().then(isLoggedIn => {
       const tip = isLoggedIn
         ? 'You might have not enough privileges.'
         : 'You might have not enough privileges. Sign in and try again.';
@@ -289,16 +283,18 @@
     return err;
   }
 
-  _handleShowAlert(e: CustomEvent) {
+  _handleShowAlert(e: ShowAlertEvent) {
     this._showAlert(
       e.detail.message,
       e.detail.action,
       e.detail.callback,
-      e.detail.dismissOnNavigation
+      e.detail.dismissOnNavigation,
+      undefined,
+      e.detail.showDismiss
     );
   }
 
-  _handleNetworkError(e: CustomEvent) {
+  _handleNetworkError(e: NetworkErrorEvent) {
     this._showAlert('Server unavailable');
     console.error(e.detail.error.message);
   }
@@ -314,7 +310,8 @@
     actionText?: string,
     actionCallback?: () => void,
     dismissOnNavigation?: boolean,
-    type?: ErrorType
+    type?: ErrorType,
+    showDismiss?: boolean
   ) {
     if (this._alertElement) {
       // check priority before hiding
@@ -332,10 +329,10 @@
         HIDE_ALERT_TIMEOUT_MS
       );
     }
-    const el = this._createToastAlert();
+    const el = this._createToastAlert(showDismiss);
     el.show(text, actionText, actionCallback);
     this._alertElement = el;
-    this.fire('iron-announce', {text}, {bubbles: true});
+    this.fire('iron-announce', {text: `Alert: ${text}`}, {bubbles: true});
     this.reporting.reportInteraction('show-alert', {text});
   }
 
@@ -371,6 +368,7 @@
       this._createLoginPopup()
     );
     this.fire('iron-announce', {text: errorText}, {bubbles: true});
+    this.reporting.reportInteraction('show-auth-error', {text: errorText});
     this._refreshingCredentials = true;
     this._requestCheckLoggedIn();
     if (!document.hidden) {
@@ -378,18 +376,16 @@
     }
   }
 
-  _createToastAlert() {
+  _createToastAlert(showDismiss?: boolean) {
     const el = document.createElement('gr-alert');
     el.toast = true;
+    el.showDismiss = !!showDismiss;
     return el;
   }
 
   _handleVisibilityChange() {
-    // Ignore when the page is transitioning to hidden (or hidden is
-    // undefined).
-    if (document.hidden !== false) {
-      return;
-    }
+    // Ignore when the page is transitioning to hidden (or hidden is undefined).
+    if (document.hidden !== false) return;
 
     // If not currently refreshing credentials and the credentials are old,
     // request them to confirm their validity or (display an auth toast if it
@@ -400,6 +396,7 @@
       this.knownAccountId !== undefined &&
       timeSinceLastCheck > STALE_CREDENTIAL_THRESHOLD_MS
     ) {
+      this.reporting.reportInteraction('visibility-sign-in-check');
       this._lastCredentialCheck = Date.now();
 
       // check auth status in case:
@@ -411,7 +408,7 @@
 
   _requestCheckLoggedIn() {
     this.debounce(
-      'checkLoggedIn',
+      DEBOUNCER_CHECK_LOGGED_IN,
       this._checkSignedIn,
       CHECK_SIGN_IN_INTERVAL_MS
     );
@@ -421,11 +418,10 @@
     this._lastCredentialCheck = Date.now();
 
     // force to refetch account info
-    this.$.restAPI.invalidateAccountsCache();
+    this.restApiService.invalidateAccountsCache();
     this._authService.clearCache();
 
-    this.$.restAPI.getLoggedIn().then(isLoggedIn => {
-      // do nothing if its refreshing
+    this.restApiService.getLoggedIn().then(isLoggedIn => {
       if (!this._refreshingCredentials) return;
 
       if (!isLoggedIn) {
@@ -435,12 +431,15 @@
         // in case #2, auth-error is taken care of separately
         this._requestCheckLoggedIn();
       } else {
-        // check account
-        this.$.restAPI.getAccount().then(account => {
+        this.restApiService.getAccount().then(account => {
           if (this._refreshingCredentials) {
-            // If the credentials were refreshed but the account is different
+            // If the credentials were refreshed but the account is different,
             // then reload the page completely.
             if (account?._account_id !== this.knownAccountId) {
+              this.reporting.reportInteraction('sign-in-window-reload', {
+                oldAccount: !!this.knownAccountId,
+                newAccount: !!account?._account_id,
+              });
               this._reloadPage();
               return;
             }
@@ -453,7 +452,7 @@
   }
 
   _reloadPage() {
-    window.location.reload();
+    windowLocationReload();
   }
 
   _createLoginPopup() {
@@ -485,7 +484,7 @@
   }
 
   _handleWindowFocus() {
-    this.flushDebouncer('checkLoggedIn');
+    this.flushDebouncer(DEBOUNCER_CHECK_LOGGED_IN);
   }
 
   _handleShowErrorDialog(e: CustomEvent) {
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_html.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_html.ts
index 1cefb78..c67ed07 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_html.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_html.ts
@@ -34,5 +34,4 @@
     no-cancel-on-outside-click=""
   >
   </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js
index f13276f..092daa2 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js
@@ -19,6 +19,8 @@
 import './gr-error-manager.js';
 import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
 import {__testOnly_ErrorType} from './gr-error-manager.js';
+import {stubRestApi} from '../../../test/test-utils.js';
+import {appContext} from '../../../services/app-context.js';
 
 const basicFixture = fixtureFromElement('gr-error-manager');
 
@@ -30,10 +32,14 @@
   suite('when authed', () => {
     let toastSpy;
     let openOverlaySpy;
+    let fetchStub;
+    let getLoggedInStub;
 
     setup(() => {
-      sinon.stub(window, 'fetch')
+      fetchStub = sinon.stub(window, 'fetch')
           .returns(Promise.resolve({ok: true, status: 204}));
+      getLoggedInStub = stubRestApi('getLoggedIn')
+          .callsFake(() => appContext.authService.authCheck());
       element = basicFixture.instantiate();
       element._authService.clearCache();
       toastSpy = sinon.spy(element, '_createToastAlert');
@@ -67,8 +73,7 @@
               element, '_showAuthErrorAlert'
           );
           const responseText = Promise.resolve('Authentication required\n');
-          sinon.stub(element.$.restAPI, 'getLoggedIn')
-              .returns(Promise.resolve(true));
+          getLoggedInStub.returns(Promise.resolve(true));
           element.dispatchEvent(
               new CustomEvent('server-error', {
                 detail:
@@ -81,36 +86,33 @@
           });
         });
 
-    test('recheck auth for 403 with auth error if authed before', done => {
-      // starts with authed state
-      element.$.restAPI.getLoggedIn();
+    test('recheck auth for 403 with auth error if authed before', async () => {
+      // Set status to AUTHED.
+      appContext.authService.authCheck();
       const responseText = Promise.resolve('Authentication required\n');
-      sinon.stub(element.$.restAPI, 'getLoggedIn')
-          .returns(Promise.resolve(true));
+      getLoggedInStub.returns(Promise.resolve(true));
       element.dispatchEvent(
           new CustomEvent('server-error', {
             detail:
           {response: {status: 403, text() { return responseText; }}},
             composed: true, bubbles: true,
           }));
-      flush(() => {
-        assert.isTrue(element.$.restAPI.getLoggedIn.calledOnce);
-        done();
-      });
+      await flush();
+      assert.isTrue(getLoggedInStub.calledOnce);
     });
 
     test('show logged in error', () => {
-      sinon.stub(element, '_showAuthErrorAlert');
+      const spy = sinon.spy(element, '_showAuthErrorAlert');
       element.dispatchEvent(
           new CustomEvent('show-auth-required', {
             composed: true, bubbles: true,
           }));
-      assert.isTrue(element._showAuthErrorAlert.calledWithExactly(
+      assert.isTrue(spy.calledWithExactly(
           'Log in is required to perform that action.', 'Log in.'));
     });
 
     test('show normal Error', done => {
-      const showErrorStub = sinon.stub(element, '_showErrorDialog');
+      const showErrorSpy = sinon.spy(element, '_showErrorDialog');
       const textSpy = sinon.spy(() => Promise.resolve('ZOMG'));
       element.dispatchEvent(
           new CustomEvent('server-error', {
@@ -120,8 +122,8 @@
 
       assert.isTrue(textSpy.called);
       flush(() => {
-        assert.isTrue(showErrorStub.calledOnce);
-        assert.isTrue(showErrorStub.lastCall.calledWithExactly(
+        assert.isTrue(showErrorSpy.calledOnce);
+        assert.isTrue(showErrorSpy.lastCall.calledWithExactly(
             'Error 500: ZOMG'));
         done();
       });
@@ -240,26 +242,27 @@
     });
 
     test('show auth refresh toast', async () => {
-      // starts with authed state
-      element.$.restAPI.getLoggedIn();
-      const refreshStub = sinon.stub(element.$.restAPI, 'getAccount').callsFake(
+      // Set status to AUTHED.
+      appContext.authService.authCheck();
+      const refreshStub = stubRestApi(
+          'getAccount').callsFake(
           () => Promise.resolve({}));
       const windowOpen = sinon.stub(window, 'open');
       const responseText = Promise.resolve('Authentication required\n');
       // fake failed auth
-      window.fetch.returns(Promise.resolve({status: 403}));
+      fetchStub.returns(Promise.resolve({status: 403}));
       element.dispatchEvent(
           new CustomEvent('server-error', {
             detail:
           {response: {status: 403, text() { return responseText; }}},
             composed: true, bubbles: true,
           }));
-      assert.equal(window.fetch.callCount, 1);
+      assert.equal(fetchStub.callCount, 1);
       await flush();
 
       // here needs two flush as there are two chanined
       // promises on server-error handler and flush only flushes one
-      assert.equal(window.fetch.callCount, 2);
+      assert.equal(fetchStub.callCount, 2);
       await flush();
       // Sometime overlay opens with delay, waiting while open is complete
       await openOverlaySpy.lastCall.returnValue;
@@ -293,7 +296,7 @@
       const hideToastSpy = sinon.spy(toast, 'hide');
 
       // now fake authed
-      window.fetch.returns(Promise.resolve({status: 204}));
+      fetchStub.returns(Promise.resolve({status: 204}));
       element._handleWindowFocus();
       element.flushDebouncer('checkLoggedIn');
       await flush();
@@ -312,8 +315,8 @@
     });
 
     test('auth toast should dismiss existing toast', async () => {
-      // starts with authed state
-      element.$.restAPI.getLoggedIn();
+      // Set status to AUTHED.
+      appContext.authService.authCheck();
       const responseText = Promise.resolve('Authentication required\n');
 
       // fake an alert
@@ -328,18 +331,18 @@
           toast.root.textContent, 'test reload');
 
       // fake auth
-      window.fetch.returns(Promise.resolve({status: 403}));
+      fetchStub.returns(Promise.resolve({status: 403}));
       element.dispatchEvent(
           new CustomEvent('server-error', {
             detail:
           {response: {status: 403, text() { return responseText; }}},
             composed: true, bubbles: true,
           }));
-      assert.equal(window.fetch.callCount, 1);
+      assert.equal(fetchStub.callCount, 1);
       await flush();
       // here needs two flush as there are two chained
       // promises on server-error handler and flush only flushes one
-      assert.equal(window.fetch.callCount, 2);
+      assert.equal(fetchStub.callCount, 2);
       await flush();
       // Sometime overlay opens with delay, waiting while open is complete
       await openOverlaySpy.lastCall.returnValue;
@@ -352,8 +355,8 @@
     });
 
     test('regular toast should dismiss regular toast', () => {
-      // starts with authed state
-      element.$.restAPI.getLoggedIn();
+      // Set status to AUTHED.
+      appContext.authService.authCheck();
 
       // fake an alert
       element.dispatchEvent(
@@ -378,23 +381,23 @@
     });
 
     test('regular toast should not dismiss auth toast', done => {
-      // starts with authed state
-      element.$.restAPI.getLoggedIn();
+      // Set status to AUTHED.
+      appContext.authService.authCheck();
       const responseText = Promise.resolve('Authentication required\n');
 
       // fake auth
-      window.fetch.returns(Promise.resolve({status: 403}));
+      fetchStub.returns(Promise.resolve({status: 403}));
       element.dispatchEvent(
           new CustomEvent('server-error', {
             detail:
           {response: {status: 403, text() { return responseText; }}},
             composed: true, bubbles: true,
           }));
-      assert.equal(window.fetch.callCount, 1);
+      assert.equal(fetchStub.callCount, 1);
       flush(() => {
         // here needs two flush as there are two chained
         // promises on server-error handler and flush only flushes one
-        assert.equal(window.fetch.callCount, 2);
+        assert.equal(fetchStub.callCount, 2);
         flush(() => {
           let toast = toastSpy.lastCall.returnValue;
           assert.include(
@@ -456,7 +459,7 @@
 
     test('refreshes with same credentials', done => {
       const accountPromise = Promise.resolve({_account_id: 1234});
-      sinon.stub(element.$.restAPI, 'getAccount')
+      stubRestApi('getAccount')
           .returns(accountPromise);
       const requestCheckStub = sinon.stub(element, '_requestCheckLoggedIn');
       const handleRefreshStub = sinon.stub(element,
@@ -513,7 +516,7 @@
 
     test('reloads when refreshed credentials differ', done => {
       const accountPromise = Promise.resolve({_account_id: 1234});
-      sinon.stub(element.$.restAPI, 'getAccount')
+      stubRestApi('getAccount')
           .returns(accountPromise);
       const requestCheckStub = sinon.stub(
           element,
@@ -538,9 +541,7 @@
   suite('when not authed', () => {
     let toastSpy;
     setup(() => {
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(false); },
-      });
+      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
       element = basicFixture.instantiate();
       toastSpy = sinon.spy(element, '_createToastAlert');
     });
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.css.ts
similarity index 61%
copy from polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
copy to polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.css.ts
index ac59f4f..ccba289 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.css.ts
@@ -14,8 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {css} from 'lit-element';
 
-export const htmlTemplate = html`
-  <gr-lib-loader id="libLoader"></gr-lib-loader>
+export const cssTemplate = css`
+  .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;
+    font-weight: var(--font-weight-bold);
+    padding: var(--spacing-xxs) var(--spacing-m);
+    text-align: center;
+  }
 `;
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts
index 796a167..091684f 100644
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts
@@ -14,12 +14,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-key-binding-display_html';
-import {customElement, property} from '@polymer/decorators';
+import {html} from 'lit-html';
+import {GrLitElement} from '../../lit/gr-lit-element';
+import {customElement, property} from 'lit-element';
+import {cssTemplate} from './gr-key-binding-display.css';
+import {sharedStyles} from '../../../styles/shared-styles';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -28,21 +27,30 @@
 }
 
 @customElement('gr-key-binding-display')
-export class GrKeyBindingDisplay extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
-  static get template() {
-    return htmlTemplate;
+export class GrKeyBindingDisplay extends GrLitElement {
+  static get styles() {
+    return [sharedStyles, cssTemplate];
+  }
+
+  render() {
+    const items = this.binding.map((binding, index) => [
+      index > 0 ? html` or ` : html``,
+      this._computeModifiers(binding).map(
+        modifier => html`<span class="key modifier">${modifier}</span> `
+      ),
+      html`<span class="key">${this._computeKey(binding)}</span>`,
+    ]);
+    return html`${items}`;
   }
 
   @property({type: Array})
   binding: string[][] = [];
 
-  _computeModifiers(binding: string[][]) {
+  _computeModifiers(binding: string[]) {
     return binding.slice(0, binding.length - 1);
   }
 
-  _computeKey(binding: string[][]) {
+  _computeKey(binding: string[]) {
     return binding[binding.length - 1];
   }
 }
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
deleted file mode 100644
index 0a75104..0000000
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_html.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    .key {
-      background-color: var(--chip-background-color);
-      border: 1px solid var(--border-color);
-      border-radius: var(--border-radius);
-      display: inline-block;
-      font-weight: var(--font-weight-bold);
-      padding: var(--spacing-xxs) var(--spacing-m);
-      text-align: center;
-    }
-  </style>
-  <template is="dom-repeat" items="[[binding]]">
-    <template is="dom-if" if="[[index]]">
-      or
-    </template>
-    <template is="dom-repeat" items="[[_computeModifiers(item)]]" as="modifier">
-      <span class="key modifier">[[modifier]]</span>
-    </template>
-    <span class="key">[[_computeKey(item)]]</span>
-  </template>
-`;
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.js b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.ts
similarity index 86%
rename from polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.js
rename to polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.ts
index 0c25e6e..875cde8 100644
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.js
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.ts
@@ -17,11 +17,12 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-key-binding-display.js';
+import {GrKeyBindingDisplay} from './gr-key-binding-display.js';
 
 const basicFixture = fixtureFromElement('gr-key-binding-display');
 
 suite('gr-key-binding-display tests', () => {
-  let element;
+  let element: GrKeyBindingDisplay;
 
   setup(() => {
     element = basicFixture.instantiate();
@@ -45,10 +46,10 @@
 
     test('key with modifiers', () => {
       assert.deepEqual(element._computeModifiers(['Ctrl', 'x']), ['Ctrl']);
-      assert.deepEqual(
-          element._computeModifiers(['Shift', 'Meta', 'x']),
-          ['Shift', 'Meta']);
+      assert.deepEqual(element._computeModifiers(['Shift', 'Meta', 'x']), [
+        'Shift',
+        'Meta',
+      ]);
     });
   });
 });
-
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 caa0521..bed07a6 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
@@ -17,8 +17,6 @@
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../shared/gr-dropdown/gr-dropdown';
 import '../../shared/gr-icons/gr-icons';
-import '../../shared/gr-js-api-interface/gr-js-api-interface';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-account-dropdown/gr-account-dropdown';
 import '../gr-smart-search/gr-smart-search';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
@@ -29,7 +27,6 @@
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {getAdminLinks, NavLink} from '../../../utils/admin-nav-util';
 import {customElement, property, observe} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {
   AccountDetailInfo,
   RequireProperties,
@@ -37,9 +34,9 @@
   TopMenuEntryInfo,
   TopMenuItemInfo,
 } from '../../../types/common';
-import {JsApiService} from '../../shared/gr-js-api-interface/gr-js-api-types';
 import {AuthType} from '../../../constants/constants';
 import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
+import {appContext} from '../../../services/app-context';
 
 type MainHeaderLink = RequireProperties<DropdownLink, 'url' | 'name'>;
 
@@ -103,13 +100,6 @@
   AuthType.CUSTOM_EXTENSION,
 ]);
 
-export interface GrMainHeader {
-  $: {
-    restAPI: RestApiService & Element;
-    jsAPI: JsApiService & Element;
-  };
-}
-
 @customElement('gr-main-header')
 export class GrMainHeader extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
@@ -158,9 +148,16 @@
   @property({type: String})
   _registerURL = '';
 
+  @property({type: String})
+  _feedbackURL = '';
+
   @property({type: Boolean})
   mobileSearchHidden = false;
 
+  private readonly restApiService = appContext.restApiService;
+
+  private readonly jsAPI = appContext.jsApiService;
+
   /** @override */
   ready() {
     super.ready();
@@ -274,8 +271,8 @@
     this.loading = true;
 
     return Promise.all([
-      this.$.restAPI.getAccount(),
-      this.$.restAPI.getTopMenus(),
+      this.restApiService.getAccount(),
+      this.restApiService.getTopMenus(),
       getPluginLoader().awaitPluginsLoaded(),
     ]).then(result => {
       const account = result[0];
@@ -287,13 +284,13 @@
       return getAdminLinks(
         account,
         () =>
-          this.$.restAPI.getAccountCapabilities().then(capabilities => {
+          this.restApiService.getAccountCapabilities().then(capabilities => {
             if (!capabilities) {
               throw new Error('getAccountCapabilities returns undefined');
             }
             return capabilities;
           }),
-        () => this.$.jsAPI.getAdminMenuLinks()
+        () => this.jsAPI.getAdminMenuLinks()
       ).then(res => {
         this._adminLinks = res.links;
       });
@@ -301,14 +298,15 @@
   }
 
   _loadConfig() {
-    this.$.restAPI
+    this.restApiService
       .getConfig()
       .then(config => {
         if (!config) {
           throw new Error('getConfig returned undefined');
         }
+        this._retrieveFeedbackURL(config);
         this._retrieveRegisterURL(config);
-        return getDocsBaseUrl(config, this.$.restAPI);
+        return getDocsBaseUrl(config, this.restApiService);
       })
       .then(docBaseUrl => {
         this._docBaseUrl = docBaseUrl;
@@ -321,12 +319,18 @@
       return;
     }
 
-    this.$.restAPI.getPreferences().then(prefs => {
+    this.restApiService.getPreferences().then(prefs => {
       this._userLinks =
         prefs && prefs.my ? prefs.my.map(this._createHeaderLink) : [];
     });
   }
 
+  _retrieveFeedbackURL(config: ServerInfo) {
+    if (config.gerrit?.report_bug_url) {
+      this._feedbackURL = config.gerrit.report_bug_url;
+    }
+  }
+
   _retrieveRegisterURL(config: ServerInfo) {
     if (AUTH_TYPES_WITH_REGISTER_URL.has(config.auth.auth_type)) {
       this._registerURL = config.auth.register_url ?? '';
@@ -348,6 +352,7 @@
     // If not, it sets target='_blank' on the menu item. The server
     // makes assumptions that work for the GWT UI, but not PolyGerrit,
     // so we'll just disable it altogether for now.
+    // eslint-disable-next-line @typescript-eslint/no-unused-vars
     const {target, ...headerLink} = {...linkObj};
 
     // Normalize all urls to PolyGerrit style.
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 5778fb8..cf7d3ae 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
@@ -79,6 +79,7 @@
       flex-grow: 1;
       margin: 0 var(--spacing-m);
       max-width: 500px;
+      min-width: 150px;
     }
     gr-dropdown,
     .browse {
@@ -92,6 +93,9 @@
     .settingsButton {
       margin-left: var(--spacing-m);
     }
+    .feedbackButton {
+      margin-left: var(--spacing-s);
+    }
     .browse {
       color: var(--header-text-color);
       /* Same as gr-button */
@@ -206,6 +210,17 @@
         class="hideOnMobile"
         name="header-browse-source"
       ></gr-endpoint-decorator>
+      <template is="dom-if" if="[[_feedbackURL]]">
+        <a class="feedbackButton"
+          href$="[[_feedbackURL]]"
+          title="File a bug"
+          aria-label="File a bug"
+          role="button"
+        >
+          <iron-icon icon="gr-icons:bug"></iron-icon>
+        </a>
+      </template>
+      </div>
       <div class="accountContainer" id="accountContainer">
         <iron-icon
           id="mobileSearch"
@@ -238,6 +253,4 @@
       </div>
     </div>
   </nav>
-  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
index 3ab40e9..d9c43d6 100644
--- 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
@@ -16,11 +16,12 @@
  */
 
 import '../../../test/common-test-setup-karma';
-import {isHidden, query} from '../../../test/test-utils';
+import {isHidden, query, stubRestApi} from '../../../test/test-utils';
 import './gr-main-header';
 import {GrMainHeader} from './gr-main-header';
 import {
   createAccountDetailWithId,
+  createGerritInfo,
   createServerInfo,
 } from '../../../test/test-data-generators';
 import {NavLink} from '../../../utils/admin-nav-util';
@@ -33,14 +34,7 @@
   let element: GrMainHeader;
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() {
-        return Promise.resolve(createServerInfo());
-      },
-      probePath(_) {
-        return Promise.resolve(false);
-      },
-    });
+    stubRestApi('probePath').returns(Promise.resolve(false));
     stub('gr-main-header', {
       _loadAccount() {
         return Promise.resolve();
@@ -482,6 +476,25 @@
     );
   });
 
+  test('shows feedback icon when URL provided', async () => {
+    assert.isEmpty(element._feedbackURL);
+    assert.isNull(query(element, '.feedbackButton'));
+
+    const url = 'report_bug_url';
+    const config: ServerInfo = {
+      ...createServerInfo(),
+      gerrit: {
+        ...createGerritInfo(),
+        report_bug_url: url,
+      },
+    };
+    element._retrieveFeedbackURL(config);
+    await flush();
+
+    assert.equal(element._feedbackURL, url);
+    assert.ok(query(element, '.feedbackButton'));
+  });
+
   test('register URL', () => {
     assert.isTrue(isHidden(query(element, '.registerDiv')));
     const config: ServerInfo = {
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
index 8470611..632ce4c 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -16,23 +16,24 @@
  */
 import {
   BranchName,
+  ChangeConfigInfo,
   ChangeInfo,
+  CommentLinks,
+  CommitId,
+  DashboardId,
+  EditPatchSetNum,
+  GroupId,
+  Hashtag,
+  NumericChangeId,
+  ParentPatchSetNum,
   PatchSetNum,
   RepoName,
-  TopicName,
-  GroupId,
-  DashboardId,
-  NumericChangeId,
-  EditPatchSetNum,
-  ChangeConfigInfo,
-  CommitId,
-  Hashtag,
-  UrlEncodedCommentId,
-  CommentLinks,
-  ParentPatchSetNum,
   ServerInfo,
+  TopicName,
+  UrlEncodedCommentId,
 } from '../../../types/common';
-import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {GerritView} from '../../../services/router/router-model';
+import {ParsedChangeInfo} from '../../../types/types';
 
 // Navigation parameters object format:
 //
@@ -248,7 +249,7 @@
 
 export interface GenerateUrlChangeViewParameters {
   view: GerritView.CHANGE;
-  // TODO(TS): NumericChangeId - not sure about it, may be it can be removeds
+  // TODO(TS): NumericChangeId - not sure about it, may be it can be removed
   changeNum: NumericChangeId;
   project: RepoName;
   patchNum?: PatchSetNum;
@@ -396,22 +397,6 @@
   url?: string;
 }
 
-export enum GerritView {
-  ADMIN = 'admin',
-  AGREEMENTS = 'agreements',
-  CHANGE = 'change',
-  DASHBOARD = 'dashboard',
-  DIFF = 'diff',
-  DOCUMENTATION_SEARCH = 'documentation-search',
-  EDIT = 'edit',
-  GROUP = 'group',
-  PLUGIN_SCREEN = 'plugin-screen',
-  REPO = 'repo',
-  ROOT = 'root',
-  SEARCH = 'search',
-  SETTINGS = 'settings',
-}
-
 export enum GroupDetailView {
   MEMBERS = 'members',
   LOG = 'log',
@@ -559,7 +544,6 @@
     return this._getUrlFor({
       view: GerritView.SEARCH,
       topic,
-      statuses: ['open', 'merged'],
       host,
     });
   },
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index 21bf900..676ef7b 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
@@ -27,6 +26,7 @@
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {
   DashboardSection,
+  GeneratedWebLink,
   GenerateUrlChangeViewParameters,
   GenerateUrlDashboardViewParameters,
   GenerateUrlDiffViewParameters,
@@ -39,19 +39,14 @@
   GenerateWebLinksFileParameters,
   GenerateWebLinksParameters,
   GenerateWebLinksPatchsetParameters,
-  GerritView,
+  GerritNav,
+  GroupDetailView,
   isGenerateUrlDiffViewParameters,
   RepoDetailView,
   WeblinkType,
-  GroupDetailView,
-  GerritNav,
-  GeneratedWebLink,
 } from '../gr-navigation/gr-navigation';
 import {appContext} from '../../../services/app-context';
-import {
-  patchNumEquals,
-  convertToPatchSetNum,
-} from '../../../utils/patch-set-util';
+import {convertToPatchSetNum} from '../../../utils/patch-set-util';
 import {customElement, property} from '@polymer/decorators';
 import {assertNever} from '../../../utils/common-util';
 import {
@@ -63,13 +58,16 @@
   ServerInfo,
   UrlEncodedCommentId,
 } from '../../../types/common';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {
   AppElement,
-  AppElementParams,
   AppElementAgreementParam,
+  AppElementParams,
 } from '../../gr-app-types';
 import {LocationChangeEventDetail} from '../../../types/events';
+import {GerritView, updateState} from '../../../services/router/router-model';
+import {firePageError} from '../../../utils/event-util';
+import {addQuotesWhen} from '../../../utils/string-util';
+import {windowLocationReload} from '../../../utils/dom-util';
 
 const RoutePattern = {
   ROOT: '/',
@@ -267,12 +265,6 @@
   });
 })();
 
-export interface GrRouter {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
-
 export interface PageContextWithQueryMap extends PageContext {
   queryMap: Map<string, string> | URLSearchParams;
 }
@@ -314,9 +306,7 @@
 
   private readonly reporting = appContext.reportingService;
 
-  constructor() {
-    super();
-  }
+  private readonly restApiService = appContext.restApiService;
 
   start() {
     if (!this._app) {
@@ -326,6 +316,11 @@
   }
 
   _setParams(params: AppElementParams | GenerateUrlParameters) {
+    updateState(
+      params.view,
+      'changeNum' in params ? params.changeNum : undefined,
+      'patchNum' in params ? params.patchNum ?? undefined : undefined
+    );
     this._appElement().params = params;
   }
 
@@ -390,9 +385,8 @@
       case WeblinkType.PATCHSET:
         return this._getPatchSetWeblink(params);
       default:
-        console.warn(`Unsupported weblink ${(params as any).type}!`);
-        // TODO(TS): use assertNever(params.type)
-        return [];
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        assertNever(params, `Unsupported weblink ${(params as any).type}!`);
     }
   }
 
@@ -484,11 +478,18 @@
       operators.push('branch:' + encodeURL(params.branch, false));
     }
     if (params.topic) {
-      operators.push('topic:"' + encodeURL(params.topic, false) + '"');
+      operators.push(
+        'topic:' +
+          addQuotesWhen(encodeURL(params.topic, false), /\s/.test(params.topic))
+      );
     }
     if (params.hashtag) {
       operators.push(
-        'hashtag:"' + encodeURL(params.hashtag.toLowerCase(), false) + '"'
+        'hashtag:' +
+          addQuotesWhen(
+            encodeURL(params.hashtag.toLowerCase(), false),
+            /\s/.test(params.hashtag)
+          )
       );
     }
     if (params.statuses) {
@@ -658,7 +659,7 @@
       return Promise.resolve();
     }
 
-    return this.$.restAPI
+    return this.restApiService
       .getFromProjectLookup(params.changeNum)
       .then(project => {
         // Show a 404 and terminate if the lookup request failed. Attempting
@@ -690,10 +691,7 @@
 
     // Diffing a patch against itself is invalid, so if the base and revision
     // patches are equal clear the base.
-    if (
-      params.patchNum &&
-      patchNumEquals(params.basePatchNum, params.patchNum)
-    ) {
+    if (params.patchNum && params.basePatchNum === params.patchNum) {
       needsRedirect = true;
       params.basePatchNum = null;
     } else if (!hasPatchNum) {
@@ -745,7 +743,7 @@
    * (if it resolves).
    */
   _redirectIfNotLoggedIn(data: PageContext) {
-    return this.$.restAPI.getLoggedIn().then(loggedIn => {
+    return this.restApiService.getLoggedIn().then(loggedIn => {
       if (loggedIn) {
         return Promise.resolve();
       } else {
@@ -757,14 +755,17 @@
 
   /**  Page.js middleware that warms the REST API's logged-in cache line. */
   _loadUserMiddleware(_: PageContext, next: PageNextCallback) {
-    this.$.restAPI.getLoggedIn().then(() => {
+    this.restApiService.getLoggedIn().then(() => {
       next();
     });
   }
 
   /**  Page.js middleware that try parse the querystring into queryMap. */
   _queryStringMiddleware(ctx: PageContext, next: PageNextCallback) {
-    let queryMap: Map<string, string> | URLSearchParams = new Map();
+    let queryMap: Map<string, string> | URLSearchParams = new Map<
+      string,
+      string
+    >();
     if (ctx.querystring) {
       // https://caniuse.com/#search=URLSearchParams
       if (window.URLSearchParams) {
@@ -796,7 +797,9 @@
     authRedirect?: boolean
   ) {
     if (!this[handlerName]) {
-      console.error('Attempted to map route to unknown method: ', handlerName);
+      this.reporting.error(
+        new Error(`Attempted to map route to unknown method: ${handlerName}`)
+      );
       return;
     }
     page(
@@ -1125,7 +1128,7 @@
       this._redirect(newUrl);
       return null;
     }
-    return this.$.restAPI.getLoggedIn().then(loggedIn => {
+    return this.restApiService.getLoggedIn().then(loggedIn => {
       if (loggedIn) {
         this._redirect('/dashboard/self');
       } else {
@@ -1183,7 +1186,7 @@
     // User dashboard. We require viewing user to be logged in, else we
     // redirect to login for self dashboard or simple owner search for
     // other user dashboard.
-    return this.$.restAPI.getLoggedIn().then(loggedIn => {
+    return this.restApiService.getLoggedIn().then(loggedIn => {
       if (!loggedIn) {
         if (data.params[0].toLowerCase() === 'self') {
           this._redirectToLogin(data.canonicalPath);
@@ -1538,10 +1541,10 @@
 
   _handleChangeRoute(ctx: PageContextWithQueryMap) {
     // Parameter order is based on the regex group number matched.
+    const changeNum = Number(ctx.params[1]) as NumericChangeId;
     const params: GenerateUrlChangeViewParameters = {
       project: ctx.params[0] as RepoName,
-      // TODO(TS): remove as unknown
-      changeNum: (ctx.params[1] as unknown) as NumericChangeId,
+      changeNum,
       basePatchNum: convertToPatchSetNum(ctx.params[4]),
       patchNum: convertToPatchSetNum(ctx.params[6]),
       view: GerritView.CHANGE,
@@ -1549,26 +1552,30 @@
     };
 
     this.reporting.setRepoName(params.project);
+    this.reporting.setChangeId(changeNum);
     this._redirectOrNavigate(params);
   }
 
   _handleCommentRoute(ctx: PageContextWithQueryMap) {
+    const changeNum = Number(ctx.params[1]) as NumericChangeId;
     const params: GenerateUrlDiffViewParameters = {
       project: ctx.params[0] as RepoName,
-      changeNum: (ctx.params[1] as unknown) as NumericChangeId,
+      changeNum,
       commentId: ctx.params[2] as UrlEncodedCommentId,
       view: GerritView.DIFF,
       commentLink: true,
     };
     this.reporting.setRepoName(params.project);
+    this.reporting.setChangeId(changeNum);
     this._redirectOrNavigate(params);
   }
 
   _handleDiffRoute(ctx: PageContextWithQueryMap) {
+    const changeNum = Number(ctx.params[1]) as NumericChangeId;
     // Parameter order is based on the regex group number matched.
     const params: GenerateUrlDiffViewParameters = {
       project: ctx.params[0] as RepoName,
-      changeNum: (ctx.params[1] as unknown) as NumericChangeId,
+      changeNum,
       basePatchNum: convertToPatchSetNum(ctx.params[4]),
       patchNum: convertToPatchSetNum(ctx.params[6]),
       path: ctx.params[8],
@@ -1580,13 +1587,14 @@
       params.lineNum = address.lineNum;
     }
     this.reporting.setRepoName(params.project);
+    this.reporting.setChangeId(changeNum);
     this._redirectOrNavigate(params);
   }
 
   _handleChangeLegacyRoute(ctx: PageContextWithQueryMap) {
     // Parameter order is based on the regex group number matched.
     const params: GenerateUrlLegacyChangeViewParameters = {
-      changeNum: (ctx.params[0] as unknown) as NumericChangeId,
+      changeNum: Number(ctx.params[0]) as NumericChangeId,
       basePatchNum: convertToPatchSetNum(ctx.params[3]),
       patchNum: convertToPatchSetNum(ctx.params[5]),
       view: GerritView.CHANGE,
@@ -1603,8 +1611,7 @@
   _handleDiffLegacyRoute(ctx: PageContextWithQueryMap) {
     // Parameter order is based on the regex group number matched.
     const params: GenerateUrlLegacyDiffViewParameters = {
-      // TODO(TS): remove "as unknown"
-      changeNum: (ctx.params[0] as unknown) as NumericChangeId,
+      changeNum: Number(ctx.params[0]) as NumericChangeId,
       basePatchNum: convertToPatchSetNum(ctx.params[2]),
       patchNum: convertToPatchSetNum(ctx.params[4]),
       path: ctx.params[5],
@@ -1623,9 +1630,10 @@
   _handleDiffEditRoute(ctx: PageContextWithQueryMap) {
     // Parameter order is based on the regex group number matched.
     const project = ctx.params[0] as RepoName;
+    const changeNum = Number(ctx.params[1]) as NumericChangeId;
     this._redirectOrNavigate({
       project,
-      changeNum: (ctx.params[1] as unknown) as NumericChangeId,
+      changeNum,
       // for edit view params, patchNum cannot be undefined
       patchNum: convertToPatchSetNum(ctx.params[2])!,
       path: ctx.params[3],
@@ -1633,20 +1641,22 @@
       view: GerritView.EDIT,
     });
     this.reporting.setRepoName(project);
+    this.reporting.setChangeId(changeNum);
   }
 
   _handleChangeEditRoute(ctx: PageContextWithQueryMap) {
     // Parameter order is based on the regex group number matched.
     const project = ctx.params[0] as RepoName;
+    const changeNum = Number(ctx.params[1]) as NumericChangeId;
     this._redirectOrNavigate({
       project,
-      // TODO(TS): remove "as unknown"
-      changeNum: (ctx.params[1] as unknown) as NumericChangeId,
+      changeNum,
       patchNum: convertToPatchSetNum(ctx.params[3]),
       view: GerritView.CHANGE,
       edit: true,
     });
     this.reporting.setRepoName(project);
+    this.reporting.setChangeId(changeNum);
   }
 
   /**
@@ -1707,7 +1717,7 @@
    * by the catchall _handleDefaultRoute handler.
    */
   _handlePassThroughRoute() {
-    location.reload();
+    windowLocationReload();
   }
 
   /**
@@ -1744,7 +1754,7 @@
 
   _handleDocumentationRedirectRoute(data: PageContextWithQueryMap) {
     if (data.params[1]) {
-      location.reload();
+      windowLocationReload();
     } else {
       // Redirect /Documentation to /Documentation/index.html
       this._redirect('/Documentation/index.html');
@@ -1768,9 +1778,7 @@
     // Note: the app's 404 display is tightly-coupled with catching 404
     // network responses, so we simulate a 404 response status to display it.
     // TODO: Decouple the gr-app error view from network responses.
-    this._appElement().dispatchEvent(
-      new CustomEvent('page-error', {detail: {response: {status: 404}}})
-    );
+    firePageError(new Response('', {status: 404}));
   }
 }
 
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_html.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router_html.ts
index 91d8b41..1489006 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_html.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_html.ts
@@ -16,6 +16,4 @@
  */
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
-export const htmlTemplate = html`
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
+export const htmlTemplate = html``;
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
index 927434b..f7fe091 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
@@ -21,6 +21,8 @@
 import {GerritNav} from '../gr-navigation/gr-navigation.js';
 import {stubBaseUrl} from '../../../test/test-utils.js';
 import {_testOnly_RoutePattern} from './gr-router.js';
+import {stubRestApi} from '../../../test/test-utils.js';
+import {addListenerForTest} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-router');
 
@@ -229,7 +231,7 @@
   });
 
   test('_redirectIfNotLoggedIn while logged in', () => {
-    sinon.stub(element.$.restAPI, 'getLoggedIn')
+    stubRestApi('getLoggedIn')
         .returns(Promise.resolve(true));
     const data = {canonicalPath: ''};
     const redirectStub = sinon.stub(element, '_redirectToLogin');
@@ -239,7 +241,7 @@
   });
 
   test('_redirectIfNotLoggedIn while logged out', () => {
-    sinon.stub(element.$.restAPI, 'getLoggedIn')
+    stubRestApi('getLoggedIn')
         .returns(Promise.resolve(false));
     const redirectStub = sinon.stub(element, '_redirectToLogin');
     const data = {canonicalPath: ''};
@@ -267,12 +269,12 @@
       };
       assert.equal(element._generateUrl(params),
           '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
-          'topic:"g%2525h"+status:op%2525en');
+          'topic:g%2525h+status:op%2525en');
 
       params.offset = 100;
       assert.equal(element._generateUrl(params),
           '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
-          'topic:"g%2525h"+status:op%2525en,100');
+          'topic:g%2525h+status:op%2525en,100');
       delete params.offset;
 
       // The presence of the query param overrides other params.
@@ -288,6 +290,19 @@
       };
       assert.equal(element._generateUrl(params),
           '/q/(status:a OR status:b OR status:c)');
+
+      params = {
+        view: GerritNav.View.SEARCH,
+        topic: 'test',
+      };
+      assert.equal(element._generateUrl(params),
+          '/q/topic:test');
+      params = {
+        view: GerritNav.View.SEARCH,
+        topic: 'test test',
+      };
+      assert.equal(element._generateUrl(params),
+          '/q/topic:"test+test"');
     });
 
     test('change', () => {
@@ -523,8 +538,7 @@
     let generateUrlStub;
 
     setup(() => {
-      projectLookupStub = sinon
-          .stub(element.$.restAPI, 'getFromProjectLookup');
+      projectLookupStub = stubRestApi('getFromProjectLookup');
       generateUrlStub = sinon.stub(element, '_generateUrl');
     });
 
@@ -664,13 +678,11 @@
     });
 
     test('_handleDefaultRoute on first load', () => {
-      const appElementStub = {dispatchEvent: sinon.stub()};
-      element._appElement = () => appElementStub;
+      const spy = sinon.spy();
+      addListenerForTest(document, 'page-error', spy);
       element._handleDefaultRoute();
-      assert.isTrue(appElementStub.dispatchEvent.calledOnce);
-      assert.equal(
-          appElementStub.dispatchEvent.lastCall.args[0].detail.response.status,
-          404);
+      assert.isTrue(spy.calledOnce);
+      assert.equal(spy.lastCall.args[0].detail.response.status, 404);
     });
 
     test('_handleDefaultRoute after internal navigation', () => {
@@ -684,8 +696,6 @@
       sinon.stub(page, 'base');
       element._startRouter();
 
-      const appElementStub = {dispatchEvent: sinon.stub()};
-      element._appElement = () => appElementStub;
       element._handleDefaultRoute();
 
       onExit('', () => {}); // we left page;
@@ -794,8 +804,7 @@
       });
 
       test('redirects to dashboard if logged in', () => {
-        sinon.stub(element.$.restAPI, 'getLoggedIn')
-            .returns(Promise.resolve(true));
+        stubRestApi('getLoggedIn').returns(Promise.resolve(true));
         const data = {
           canonicalPath: '/', path: '/', querystring: '', hash: '',
         };
@@ -807,8 +816,7 @@
       });
 
       test('redirects to open changes if not logged in', () => {
-        sinon.stub(element.$.restAPI, 'getLoggedIn')
-            .returns(Promise.resolve(false));
+        stubRestApi('getLoggedIn').returns(Promise.resolve(false));
         const data = {
           canonicalPath: '/', path: '/', querystring: '', hash: '',
         };
@@ -905,8 +913,7 @@
       });
 
       test('own dashboard but signed out redirects to login', () => {
-        sinon.stub(element.$.restAPI, 'getLoggedIn')
-            .returns(Promise.resolve(false));
+        stubRestApi('getLoggedIn').returns(Promise.resolve(false));
         const data = {canonicalPath: '/dashboard/', params: {0: 'seLF'}};
         return element._handleDashboardRoute(data, '').then(() => {
           assert.isTrue(redirectToLoginStub.calledOnce);
@@ -916,8 +923,7 @@
       });
 
       test('non-self dashboard but signed out does not redirect', () => {
-        sinon.stub(element.$.restAPI, 'getLoggedIn')
-            .returns(Promise.resolve(false));
+        stubRestApi('getLoggedIn').returns(Promise.resolve(false));
         const data = {canonicalPath: '/dashboard/', params: {0: 'foo'}};
         return element._handleDashboardRoute(data, '').then(() => {
           assert.isFalse(redirectToLoginStub.called);
@@ -928,8 +934,7 @@
       });
 
       test('dashboard while signed in sets params', () => {
-        sinon.stub(element.$.restAPI, 'getLoggedIn')
-            .returns(Promise.resolve(true));
+        stubRestApi('getLoggedIn').returns(Promise.resolve(true));
         const data = {canonicalPath: '/dashboard/', params: {0: 'foo'}};
         return element._handleDashboardRoute(data, '').then(() => {
           assert.isFalse(redirectToLoginStub.called);
@@ -1444,7 +1449,7 @@
         setup(() => {
           normalizeRangeStub = sinon.stub(element,
               '_normalizePatchRangeParams');
-          sinon.stub(element.$.restAPI, 'setInProjectLookup');
+          stubRestApi('setInProjectLookup');
         });
 
         test('needs redirect', () => {
@@ -1498,7 +1503,7 @@
         setup(() => {
           normalizeRangeStub = sinon.stub(element,
               '_normalizePatchRangeParams');
-          sinon.stub(element.$.restAPI, 'setInProjectLookup');
+          stubRestApi('setInProjectLookup');
         });
 
         test('needs redirect', () => {
@@ -1540,7 +1545,7 @@
           ]);
           assertDataToParams({params: groups.slice(1)}, '_handleCommentRoute', {
             project: 'gerrit',
-            changeNum: '264833',
+            changeNum: 264833,
             commentId: '00049681_f34fd6a9',
             commentLink: true,
             view: GerritNav.View.DIFF,
@@ -1551,7 +1556,7 @@
       test('_handleDiffEditRoute', () => {
         const normalizeRangeSpy =
             sinon.spy(element, '_normalizePatchRangeParams');
-        sinon.stub(element.$.restAPI, 'setInProjectLookup');
+        stubRestApi('setInProjectLookup');
         const ctx = {
           params: [
             'foo/bar', // 0 Project
@@ -1580,7 +1585,7 @@
       test('_handleDiffEditRoute with lineNum', () => {
         const normalizeRangeSpy =
             sinon.spy(element, '_normalizePatchRangeParams');
-        sinon.stub(element.$.restAPI, 'setInProjectLookup');
+        stubRestApi('setInProjectLookup');
         const ctx = {
           params: [
             'foo/bar', // 0 Project
@@ -1610,7 +1615,7 @@
       test('_handleChangeEditRoute', () => {
         const normalizeRangeSpy =
             sinon.spy(element, '_normalizePatchRangeParams');
-        sinon.stub(element.$.restAPI, 'setInProjectLookup');
+        stubRestApi('setInProjectLookup');
         const ctx = {
           params: [
             'foo/bar', // 0 Project
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 3f53847..32e0083 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
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 import '../../shared/gr-autocomplete/gr-autocomplete';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/shared-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
@@ -28,7 +27,6 @@
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {customElement, property} from '@polymer/decorators';
 import {ServerInfo} from '../../../types/common';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {
   AutocompleteQuery,
   AutocompleteSuggestion,
@@ -37,6 +35,7 @@
 import {getDocsBaseUrl} from '../../../utils/url-util';
 import {CustomKeyboardEvent} from '../../../types/events';
 import {MergeabilityComputationBehavior} from '../../../constants/constants';
+import {appContext} from '../../../services/app-context';
 
 // Possible static search options for auto complete, without negations.
 const SEARCH_OPERATORS: ReadonlyArray<string> = [
@@ -99,6 +98,7 @@
   'onlyextensions:',
   'owner:',
   'ownerin:',
+  'parentof:',
   'parentproject:',
   'project:',
   'projects:',
@@ -141,7 +141,6 @@
 
 export interface GrSearchBar {
   $: {
-    restAPI: RestApiService & Element;
     searchInput: GrAutocomplete;
   };
 }
@@ -192,6 +191,8 @@
   @property({type: String})
   docBaseUrl: string | null = null;
 
+  private readonly restApiService = appContext.restApiService;
+
   constructor() {
     super();
     this.query = (input: string) => this._getSearchSuggestions(input);
@@ -199,7 +200,7 @@
 
   attached() {
     super.attached();
-    this.$.restAPI.getConfig().then((serverConfig?: ServerInfo) => {
+    this.restApiService.getConfig().then((serverConfig?: ServerInfo) => {
       const mergeability =
         serverConfig &&
         serverConfig.change &&
@@ -214,7 +215,7 @@
         this._addOperator('is:mergeable');
       }
       if (serverConfig) {
-        getDocsBaseUrl(serverConfig, this.$.restAPI).then(baseUrl => {
+        getDocsBaseUrl(serverConfig, this.restApiService).then(baseUrl => {
           this.docBaseUrl = baseUrl;
         });
       }
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.ts
index 2fbdc7e..a0de7f2 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.ts
@@ -47,6 +47,7 @@
         href$="[[_computeHelpDocLink(docBaseUrl)]]"
         target="_blank"
         class="help"
+        tabindex="-1"
       >
         <iron-icon
           icon="gr-icons:help-outline"
@@ -55,5 +56,4 @@
       </a>
     </gr-autocomplete>
   </form>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
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 e470618..e553402 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
@@ -21,6 +21,7 @@
 import {TestKeyboardShortcutBinder} from '../../../test/test-utils.js';
 import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
 import {_testOnly_clearDocsBaseUrlCache} from '../../../utils/url-util.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-search-bar');
 
@@ -123,6 +124,12 @@
   });
 
   suite('_getSearchSuggestions', () => {
+    setup(() => {
+      // Ensure that config.change.mergeability_computation_behavior is not set.
+      stubRestApi('getConfig').returns(Promise.resolve({}));
+      element = basicFixture.instantiate();
+    });
+
     test('Autocompletes accounts', () => {
       sinon.stub(element, 'accountSuggestions').callsFake(() =>
         Promise.resolve([{text: 'owner:fred@goog.co'}])
@@ -188,15 +195,11 @@
   ].forEach(mergeability => {
     suite(`mergeability as ${mergeability}`, () => {
       setup(done => {
-        stub('gr-rest-api-interface', {
-          getConfig() {
-            return Promise.resolve({
-              change: {
-                mergeability_computation_behavior: mergeability,
-              },
-            });
+        stubRestApi('getConfig').returns(Promise.resolve({
+          change: {
+            mergeability_computation_behavior: mergeability,
           },
-        });
+        }));
 
         element = basicFixture.instantiate();
         flush(done);
@@ -217,15 +220,11 @@
 
   suite('doc url', () => {
     setup(done => {
-      stub('gr-rest-api-interface', {
-        getConfig() {
-          return Promise.resolve({
-            gerrit: {
-              doc_url: 'https://doc.com/',
-            },
-          });
+      stubRestApi('getConfig').returns(Promise.resolve({
+        gerrit: {
+          doc_url: 'https://doc.com/',
         },
-      });
+      }));
 
       _testOnly_clearDocsBaseUrlCache();
       element = basicFixture.instantiate();
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
index a818c59..b698732 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-search-bar/gr-search-bar';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
@@ -23,24 +22,18 @@
 import {GerritNav} from '../gr-navigation/gr-navigation';
 import {getUserName} from '../../../utils/display-name-util';
 import {customElement, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {AccountInfo, ServerInfo} from '../../../types/common';
 import {
   SearchBarHandleSearchDetail,
   SuggestionProvider,
 } from '../gr-search-bar/gr-search-bar';
 import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {appContext} from '../../../services/app-context';
 
 const MAX_AUTOCOMPLETE_RESULTS = 10;
 const SELF_EXPRESSION = 'self';
 const ME_EXPRESSION = 'me';
 
-export interface GrSmartSearch {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
-
 @customElement('gr-smart-search')
 export class GrSmartSearch extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
@@ -70,10 +63,12 @@
   @property({type: String})
   label = '';
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
   attached() {
     super.attached();
-    this.$.restAPI.getConfig().then(cfg => {
+    this.restApiService.getConfig().then(cfg => {
       this._config = cfg;
     });
   }
@@ -97,7 +92,7 @@
     predicate: string,
     expression: string
   ): Promise<AutocompleteSuggestion[]> {
-    return this.$.restAPI
+    return this.restApiService
       .getSuggestedProjects(expression, MAX_AUTOCOMPLETE_RESULTS)
       .then(projects => {
         if (!projects) {
@@ -125,7 +120,7 @@
     if (expression.length === 0) {
       return Promise.resolve([]);
     }
-    return this.$.restAPI
+    return this.restApiService
       .getSuggestedGroups(expression, MAX_AUTOCOMPLETE_RESULTS)
       .then(groups => {
         if (!groups) {
@@ -153,7 +148,7 @@
     if (expression.length === 0) {
       return Promise.resolve([]);
     }
-    return this.$.restAPI
+    return this.restApiService
       .getSuggestedAccounts(expression, MAX_AUTOCOMPLETE_RESULTS)
       .then(accounts => {
         if (!accounts) {
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_html.ts b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_html.ts
index 7088937..c08df15 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_html.ts
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_html.ts
@@ -27,5 +27,4 @@
     group-suggestions="[[_groupSuggestions]]"
     account-suggestions="[[_accountSuggestions]]"
   ></gr-search-bar>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.js b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.js
index dc7bf0e..f3a9965 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.js
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-smart-search.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-smart-search');
 
@@ -28,7 +29,7 @@
   });
 
   test('Autocompletes accounts', () => {
-    sinon.stub(element.$.restAPI, 'getSuggestedAccounts').callsFake(() =>
+    stubRestApi('getSuggestedAccounts').callsFake(() =>
       Promise.resolve([
         {
           name: 'fred',
@@ -42,7 +43,7 @@
   });
 
   test('Inserts self as option when valid', () => {
-    sinon.stub(element.$.restAPI, 'getSuggestedAccounts').callsFake( () =>
+    stubRestApi('getSuggestedAccounts').callsFake( () =>
       Promise.resolve([
         {
           name: 'fred',
@@ -62,7 +63,7 @@
   });
 
   test('Inserts me as option when valid', () => {
-    sinon.stub(element.$.restAPI, 'getSuggestedAccounts').callsFake( () =>
+    stubRestApi('getSuggestedAccounts').callsFake( () =>
       Promise.resolve([
         {
           name: 'fred',
@@ -82,7 +83,7 @@
   });
 
   test('Autocompletes groups', () => {
-    sinon.stub(element.$.restAPI, 'getSuggestedGroups').callsFake( () =>
+    stubRestApi('getSuggestedGroups').callsFake( () =>
       Promise.resolve({
         Polygerrit: 0,
         gerrit: 0,
@@ -95,7 +96,7 @@
   });
 
   test('Autocompletes projects', () => {
-    sinon.stub(element.$.restAPI, 'getSuggestedProjects').callsFake( () =>
+    stubRestApi('getSuggestedProjects').callsFake( () =>
       Promise.resolve({Polygerrit: 0}));
     return element._fetchProjects('project', 'pol').then(s => {
       assert.deepEqual(s[0], {text: 'project:Polygerrit'});
@@ -103,7 +104,7 @@
   });
 
   test('Autocomplete doesnt override exact matches to input', () => {
-    sinon.stub(element.$.restAPI, 'getSuggestedGroups').callsFake( () =>
+    stubRestApi('getSuggestedGroups').callsFake( () =>
       Promise.resolve({
         Polygerrit: 0,
         gerrit: 0,
@@ -118,7 +119,7 @@
   });
 
   test('Autocompletes accounts with no email', () => {
-    sinon.stub(element.$.restAPI, 'getSuggestedAccounts').callsFake( () =>
+    stubRestApi('getSuggestedAccounts').callsFake( () =>
       Promise.resolve([{name: 'fred'}]));
     return element._fetchAccounts('owner', 'fr').then(s => {
       assert.deepEqual(s[0], {text: 'owner:"fred"', label: 'fred'});
@@ -126,7 +127,7 @@
   });
 
   test('Autocompletes accounts with email', () => {
-    sinon.stub(element.$.restAPI, 'getSuggestedAccounts').callsFake( () =>
+    stubRestApi('getSuggestedAccounts').callsFake( () =>
       Promise.resolve([{email: 'fred@goog.co'}]));
     return element._fetchAccounts('owner', 'fr').then(s => {
       assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: ''});
diff --git a/polygerrit-ui/app/elements/custom-dark-theme_test.js b/polygerrit-ui/app/elements/custom-dark-theme_test.js
index ad12e14..d4b05e2 100644
--- a/polygerrit-ui/app/elements/custom-dark-theme_test.js
+++ b/polygerrit-ui/app/elements/custom-dark-theme_test.js
@@ -17,7 +17,6 @@
 
 import '../test/common-test-setup-karma.js';
 import {getComputedStyleValue} from '../utils/dom-util.js';
-import './shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import './gr-app.js';
 import {getPluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader.js';
 import {removeTheme} from '../styles/themes/dark-theme.js';
diff --git a/polygerrit-ui/app/elements/custom-light-theme_test.js b/polygerrit-ui/app/elements/custom-light-theme_test.js
index 6d5b61e..35bc3a6 100644
--- a/polygerrit-ui/app/elements/custom-light-theme_test.js
+++ b/polygerrit-ui/app/elements/custom-light-theme_test.js
@@ -17,9 +17,9 @@
 
 import '../test/common-test-setup-karma.js';
 import {getComputedStyleValue} from '../utils/dom-util.js';
-import './shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import './gr-app.js';
 import {getPluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader.js';
+import {stubRestApi} from '../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-app');
 
@@ -27,14 +27,11 @@
   let element;
   setup(done => {
     window.localStorage.removeItem('dark-theme');
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({test: 'config'}); },
-      getAccount() { return Promise.resolve({}); },
-      getDiffComments() { return Promise.resolve({}); },
-      getDiffRobotComments() { return Promise.resolve({}); },
-      getDiffDrafts() { return Promise.resolve({}); },
-      _fetchSharedCacheURL() { return Promise.resolve({}); },
-    });
+    stubRestApi('getConfig').returns(Promise.resolve({test: 'config'}));
+    stubRestApi('getAccount').returns(Promise.resolve({}));
+    stubRestApi('getDiffComments').returns(Promise.resolve({}));
+    stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+    stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
     element = basicFixture.instantiate();
     getPluginLoader().loadPlugins([]);
     getPluginLoader().awaitPluginsLoaded()
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..e381213 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
@@ -18,7 +18,6 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-diff/gr-diff';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
@@ -26,25 +25,24 @@
 import {htmlTemplate} from './gr-apply-fix-dialog_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {customElement, property} from '@polymer/decorators';
-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';
 import {OpenFixPreviewEvent} from '../../../types/events';
+import {appContext} from '../../../services/app-context';
+import {fireEvent} from '../../../utils/event-util';
+import {ParsedChangeInfo} from '../../../types/types';
 
 export interface GrApplyFixDialog {
   $: {
-    restAPI: RestApiService & Element;
     applyFixOverlay: GrOverlay;
   };
 }
@@ -102,6 +100,8 @@
 
   private refitOverlay?: () => void;
 
+  private readonly restApiService = appContext.restApiService;
+
   /**
    * Given robot comment CustomEvent object, fetch diffs associated
    * with first robot comment suggested fix and open dialog.
@@ -130,12 +130,7 @@
     );
     return Promise.all(promises).then(() => {
       // ensures gr-overlay repositions overlay in center
-      this.$.applyFixOverlay.dispatchEvent(
-        new CustomEvent('iron-resize', {
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireEvent(this.$.applyFixOverlay, 'iron-resize');
     });
   }
 
@@ -143,12 +138,7 @@
     super.attached();
     this.refitOverlay = () => {
       // re-center the dialog as content changed
-      this.$.applyFixOverlay.dispatchEvent(
-        new CustomEvent('iron-resize', {
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireEvent(this.$.applyFixOverlay, 'iron-resize');
     };
     this.addEventListener('diff-context-expanded', this.refitOverlay);
   }
@@ -171,7 +161,7 @@
         new Error('Both _patchNum and changeNum must be set')
       );
     }
-    return this.$.restAPI
+    return this.restApiService
       .getRobotCommentFixPreview(this.changeNum, this._patchNum, fixId)
       .then(res => {
         if (res) {
@@ -243,12 +233,7 @@
     this._currentPreviews = [];
     this._isApplyFixLoading = false;
 
-    this.dispatchEvent(
-      new CustomEvent('close-fix-preview', {
-        bubbles: true,
-        composed: true,
-      })
-    );
+    fireEvent(this, 'close-fix-preview');
     this.$.applyFixOverlay.close();
   }
 
@@ -291,7 +276,7 @@
       return Promise.reject(new Error('Not all required properties are set.'));
     }
     this._isApplyFixLoading = true;
-    return this.$.restAPI
+    return this.restApiService
       .applyFixSuggestion(changeNum, patchNum, this._currentFix.fix_id)
       .then(res => {
         if (res && res.ok) {
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.ts
index 057fd01..52fa9841 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.ts
@@ -95,5 +95,4 @@
       </div>
     </gr-dialog>
   </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.js b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.js
index cb73885..d78961c 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-apply-fix-dialog.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-apply-fix-dialog');
 
@@ -56,7 +57,7 @@
 
   suite('dialog open', () => {
     setup(() => {
-      sinon.stub(element.$.restAPI, 'getRobotCommentFixPreview')
+      stubRestApi('getRobotCommentFixPreview')
           .returns(Promise.resolve({
             f1: {
               meta_a: {},
@@ -147,8 +148,7 @@
   });
 
   test('next button state updated when suggestions changed', done => {
-    sinon.stub(element.$.restAPI, 'getRobotCommentFixPreview')
-        .returns(Promise.resolve({}));
+    stubRestApi('getRobotCommentFixPreview').returns(Promise.resolve({}));
     sinon.stub(element.$.applyFixOverlay, 'open').returns(Promise.resolve());
 
     element.open({detail: {patchNum: 2, comment: ROBOT_COMMENT_WITH_ONE_FIX}})
@@ -162,38 +162,24 @@
         });
   });
 
-  test('preview endpoint throws error should reset dialog', done => {
-    sinon.stub(window, 'fetch').callsFake((url => {
-      if (url.endsWith('/preview')) {
-        return Promise.reject(new Error('backend error'));
-      }
-      return Promise.resolve({
-        ok: true,
-        text() { return Promise.resolve(''); },
-        status: 200,
-      });
-    }));
-    const errorStub = sinon.stub();
-    document.addEventListener('network-error', errorStub);
+  test('preview endpoint throws error should reset dialog', async () => {
+    stubRestApi('getRobotCommentFixPreview').returns(
+        Promise.reject(new Error('backend error')));
     element.open({detail: {patchNum: 2,
       comment: ROBOT_COMMENT_WITH_TWO_FIXES}});
-    flush(() => {
-      assert.isTrue(errorStub.called);
-      assert.equal(element._currentFix, undefined);
-      done();
-    });
+    await flush();
+    assert.equal(element._currentFix, undefined);
   });
 
   test('apply fix button should call apply ' +
   'and navigate to change view', () => {
-    sinon.stub(element.$.restAPI, 'applyFixSuggestion')
-        .returns(Promise.resolve({ok: true}));
+    const stub = stubRestApi('applyFixSuggestion').returns(
+        Promise.resolve({ok: true}));
     sinon.stub(GerritNav, 'navigateToChange');
     element._currentFix = {fix_id: '123'};
 
     return element._handleApplyFix().then(() => {
-      assert.isTrue(element.$.restAPI.applyFixSuggestion
-          .calledWithExactly('1', 2, '123'));
+      assert.isTrue(stub.calledWithExactly('1', 2, '123'));
       assert.isTrue(GerritNav.navigateToChange.calledWithExactly({
         _number: '1',
         project: 'project',
@@ -211,14 +197,12 @@
   });
 
   test('should not navigate to change view if incorect reponse', done => {
-    sinon.stub(element.$.restAPI, 'applyFixSuggestion')
-        .returns(Promise.resolve({}));
+    const stub = stubRestApi('applyFixSuggestion').returns(Promise.resolve({}));
     sinon.stub(GerritNav, 'navigateToChange');
     element._currentFix = {fix_id: '123'};
 
     element._handleApplyFix().then(() => {
-      assert.isTrue(element.$.restAPI.applyFixSuggestion
-          .calledWithExactly('1', 2, '123'));
+      assert.isTrue(stub.calledWithExactly('1', 2, '123'));
       assert.isTrue(GerritNav.navigateToChange.notCalled);
 
       assert.equal(element._isApplyFixLoading, false);
@@ -227,7 +211,7 @@
   });
 
   test('select fix forward and back of multiple suggested fixes', done => {
-    sinon.stub(element.$.restAPI, 'getRobotCommentFixPreview')
+    stubRestApi('getRobotCommentFixPreview')
         .returns(Promise.resolve({
           f1: {
             meta_a: {},
@@ -272,27 +256,17 @@
         });
   });
 
-  test('server-error should throw for failed apply call', done => {
-    sinon.stub(window, 'fetch').callsFake((url => {
-      if (url.endsWith('/apply')) {
-        return Promise.reject(new Error('backend error'));
-      }
-      return Promise.resolve({
-        ok: true,
-        text() { return Promise.resolve(''); },
-        status: 200,
-      });
-    }));
-    const errorStub = sinon.stub();
-    document.addEventListener('network-error', errorStub);
+  test('server-error should throw for failed apply call', async () => {
+    stubRestApi('applyFixSuggestion').returns(
+        Promise.reject(new Error('backend error')));
     sinon.stub(GerritNav, 'navigateToChange');
     element._currentFix = {fix_id: '123'};
-    element._handleApplyFix();
-    flush(() => {
-      assert.isFalse(GerritNav.navigateToChange.called);
-      assert.isTrue(errorStub.called);
-      done();
+    let expectedError;
+    await element._handleApplyFix().catch(e => {
+      expectedError = e;
     });
+    assert.isOk(expectedError);
+    assert.isFalse(GerritNav.navigateToChange.called);
   });
 });
 
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 2f6a1b4..b60a585 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
@@ -14,61 +14,50 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-comment-api_html';
-import {
-  getParentIndex,
-  isMergeParent,
-  patchNumEquals,
-} from '../../../utils/patch-set-util';
+import {CURRENT} from '../../../utils/patch-set-util';
 import {customElement, property} from '@polymer/decorators';
 import {
   CommentBasics,
-  ConfigInfo,
-  ParentPatchSetNum,
   PatchRange,
   PatchSetNum,
   PathToRobotCommentsInfoMap,
   RobotCommentInfo,
   UrlEncodedCommentId,
   NumericChangeId,
+  PathToCommentsInfoMap,
+  FileInfo,
+  ParentPatchSetNum,
 } from '../../../types/common';
-import {hasOwnProperty} from '../../../utils/common-util';
-import {CommentSide} from '../../../constants/constants';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {
   Comment,
   CommentMap,
   CommentThread,
   DraftInfo,
   isUnresolved,
-  sortComments,
   UIComment,
   UIDraft,
   UIHuman,
   UIRobot,
+  createCommentThreads,
+  isInPatchRange,
+  isDraftThread,
+  isInBaseOfPatchRange,
+  isInRevisionOfPatchRange,
 } from '../../../utils/comment-util';
 import {PatchSetFile, PatchNumOnly, isPatchSetFile} from '../../../types/types';
+import {appContext} from '../../../services/app-context';
+import {CommentSide, Side} from '../../../constants/constants';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {pluralize} from '../../../utils/string-util';
 
 export type CommentIdToCommentThreadMap = {
   [urlEncodedCommentId: string]: CommentThread;
 };
 
-export interface TwoSidesComments {
-  // TODO(TS): remove meta - it is not used anywhere
-  meta: {
-    changeNum: NumericChangeId;
-    path: string;
-    patchRange: PatchRange;
-    projectConfig?: ConfigInfo;
-  };
-  left: UIComment[];
-  right: UIComment[];
-}
-
 export class ChangeComments {
   private readonly _comments: {[path: string]: UIHuman[]};
 
@@ -76,7 +65,9 @@
 
   private readonly _drafts: {[path: string]: UIDraft[]};
 
-  private readonly _changeNum: NumericChangeId;
+  private readonly _portedComments: PathToCommentsInfoMap;
+
+  private readonly _portedDrafts: PathToCommentsInfoMap;
 
   /**
    * Construct a change comments object, which can be data-bound to child
@@ -86,13 +77,14 @@
     comments: {[path: string]: UIHuman[]} | undefined,
     robotComments: {[path: string]: UIRobot[]} | undefined,
     drafts: {[path: string]: UIDraft[]} | undefined,
-    changeNum: NumericChangeId
+    portedComments: PathToCommentsInfoMap | undefined,
+    portedDrafts: PathToCommentsInfoMap | undefined
   ) {
     this._comments = this._addPath(comments);
     this._robotComments = this._addPath(robotComments);
     this._drafts = this._addPath(drafts);
-    // TODO(TS): remove changeNum param - it is not used anywhere
-    this._changeNum = changeNum;
+    this._portedComments = portedComments || {};
+    this._portedDrafts = portedDrafts || {};
   }
 
   /**
@@ -117,18 +109,10 @@
     return updatedComments;
   }
 
-  get comments() {
-    return this._comments;
-  }
-
   get drafts() {
     return this._drafts;
   }
 
-  get robotComments() {
-    return this._robotComments;
-  }
-
   findCommentById(commentId?: UrlEncodedCommentId): UIComment | undefined {
     if (!commentId) return undefined;
     const findComment = (comments: {[path: string]: UIComment[]}) => {
@@ -158,21 +142,17 @@
    */
   getPaths(patchRange?: PatchRange): CommentMap {
     const responses: {[path: string]: UIComment[]}[] = [
-      this.comments,
+      this._comments,
       this.drafts,
-      this.robotComments,
+      this._robotComments,
     ];
     const commentMap: CommentMap = {};
     for (const response of responses) {
-      for (const path in response) {
+      for (const [path, comments] of Object.entries(response)) {
         if (
-          hasOwnProperty(response, path) &&
-          response[path].some(c => {
+          comments.some(c => {
             // If don't care about patch range, we know that the path exists.
-            if (!patchRange) {
-              return true;
-            }
-            return this._isInPatchRange(c, patchRange);
+            return !patchRange || isInPatchRange(c, patchRange);
           })
         ) {
           commentMap[path] = true;
@@ -251,9 +231,7 @@
       allComments = allComments.concat(drafts);
     }
     if (patchNum) {
-      allComments = allComments.filter(c =>
-        patchNumEquals(c.patch_set, patchNum)
-      );
+      allComments = allComments.filter(c => c.patch_set === patchNum);
     }
     return allComments.map(c => {
       return {...c};
@@ -281,6 +259,29 @@
     return allComments;
   }
 
+  cloneWithUpdatedDrafts(drafts: {[path: string]: UIDraft[]} | undefined) {
+    return new ChangeComments(
+      this._comments,
+      this._robotComments,
+      drafts,
+      this._portedComments,
+      this._portedDrafts
+    );
+  }
+
+  cloneWithUpdatedPortedComments(
+    portedComments?: PathToCommentsInfoMap,
+    portedDrafts?: PathToCommentsInfoMap
+  ) {
+    return new ChangeComments(
+      this._comments,
+      this._robotComments,
+      this._drafts,
+      portedComments,
+      portedDrafts
+    );
+  }
+
   /**
    * Get the drafts for a path and optional patch num.
    *
@@ -290,7 +291,7 @@
   getAllDraftsForPath(path: string, patchNum?: PatchSetNum): Comment[] {
     let comments = this._drafts[path] || [];
     if (patchNum) {
-      comments = comments.filter(c => patchNumEquals(c.patch_set, patchNum));
+      comments = comments.filter(c => c.patch_set === patchNum);
     }
     return comments.map(c => {
       return {...c, __draft: true};
@@ -322,52 +323,142 @@
    * @param projectConfig Optional project config object to
    * include in the meta sub-object.
    */
-  getCommentsBySideForPath(
-    path: string,
-    patchRange: PatchRange,
-    projectConfig?: ConfigInfo
-  ): TwoSidesComments {
+  getCommentsForPath(path: string, patchRange: PatchRange): Comment[] {
     let comments: Comment[] = [];
     let drafts: DraftInfo[] = [];
     let robotComments: RobotCommentInfo[] = [];
-    if (this.comments && this.comments[path]) {
-      comments = this.comments[path];
+    if (this._comments && this._comments[path]) {
+      comments = this._comments[path];
     }
     if (this.drafts && this.drafts[path]) {
       drafts = this.drafts[path];
     }
-    if (this.robotComments && this.robotComments[path]) {
-      robotComments = this.robotComments[path];
+    if (this._robotComments && this._robotComments[path]) {
+      robotComments = this._robotComments[path];
     }
 
     drafts.forEach(d => {
       d.__draft = true;
     });
 
-    const all: Comment[] = comments
+    return comments
       .concat(drafts)
       .concat(robotComments)
+      .filter(c => isInPatchRange(c, patchRange))
       .map(c => {
         return {...c};
       });
+  }
 
-    const baseComments = all.filter(c =>
-      this._isInBaseOfPatchRange(c, patchRange)
-    );
-    const revisionComments = all.filter(c =>
-      this._isInRevisionOfPatchRange(c, patchRange)
-    );
+  /**
+   * Get the ported threads for given patch range.
+   * Ported threads are comment threads that were posted on an older patchset
+   * and are displayed on a later patchset.
+   * It is simply the original thread displayed on a newer patchset.
+   *
+   * Threads are ported over to all subsequent patchsets. So, a thread created
+   * on patchset 5 say will be ported over to patchsets 6,7,8 and beyond.
+   *
+   * Ported threads add a boolean property ported true to the thread object
+   * to indicate to the user that this is a ported thread.
+   *
+   * Any interactions with ported threads are reflected on the original threads.
+   * Replying to a ported thread ported from Patchset 6 shown on Patchset 10
+   * say creates a draft reply associated with Patchset 6, since the user is
+   * interacting with the original thread.
+   *
+   * Only threads with unresolved comments or drafts are ported over.
+   * If the thread is associated with either the left patchset or the right
+   * patchset, then we filter that ported thread from the return value
+   * as it will be rendered by default.
+   *
+   * If there is no appropriate range for the ported comments, then the backend
+   * does not return the range of the ported thread and it becomes a file level
+   * thread.
+   *
+   * If a comment was created with Side=PARENT, then we only show this ported
+   * comment if Base is part of the patch range, always on the left side of
+   * the diff.
+   *
+   * @return only the ported threads for the specified file and patch range
+   */
+  _getPortedCommentThreads(
+    file: PatchSetFile,
+    patchRange: PatchRange
+  ): CommentThread[] {
+    const portedComments = this._portedComments[file.path] || [];
+    portedComments.push(...(this._portedDrafts[file.path] || []));
+    if (file.basePath) {
+      portedComments.push(...(this._portedComments[file.basePath] || []));
+      portedComments.push(...(this._portedDrafts[file.basePath] || []));
+    }
+    if (!portedComments.length) return [];
 
-    return {
-      meta: {
-        changeNum: this._changeNum,
-        path,
-        patchRange,
-        projectConfig,
-      },
-      left: baseComments,
-      right: revisionComments,
-    };
+    // when forming threads in diff view, we filter for current patchrange but
+    // ported comments will involve comments that may not belong to the
+    // current patchrange, so we need to form threads for them using all
+    // comments
+    const allComments: UIComment[] = this.getAllCommentsForFile(file, true);
+
+    return createCommentThreads(allComments).filter(thread => {
+      // Robot comments and drafts are not ported over. A human reply to
+      // the robot comment will be ported over, thefore it's possible to
+      // have the root comment of the thread not be ported, hence loop over
+      // entire thread
+      const portedComment = portedComments.find(portedComment =>
+        thread.comments.some(c => portedComment.id === c.id)
+      );
+      if (!portedComment) return false;
+
+      const originalComment = thread.comments.find(
+        comment => comment.id === portedComment.id
+      )!;
+
+      if (
+        (originalComment.line && !portedComment.line) ||
+        (originalComment.range && !portedComment.range)
+      ) {
+        thread.rangeInfoLost = true;
+      }
+
+      if (
+        isInBaseOfPatchRange(thread.comments[0], patchRange) ||
+        isInRevisionOfPatchRange(thread.comments[0], patchRange)
+      ) {
+        // no need to port this thread as it will be rendered by default
+        return false;
+      }
+
+      thread.diffSide = Side.RIGHT;
+      if (thread.commentSide === CommentSide.PARENT) {
+        // TODO(dhruvsri): Add handling for merge parents
+        if (
+          patchRange.basePatchNum !== ParentPatchSetNum ||
+          !!thread.mergeParentNum
+        )
+          return false;
+        thread.diffSide = Side.LEFT;
+      }
+
+      if (!isUnresolved(thread) && !isDraftThread(thread)) return false;
+
+      thread.range = portedComment.range;
+      thread.line = portedComment.line;
+      thread.ported = true;
+      return true;
+    });
+  }
+
+  getThreadsBySideForFile(
+    file: PatchSetFile,
+    patchRange: PatchRange
+  ): CommentThread[] {
+    const threads = createCommentThreads(
+      this.getCommentsForFile(file, patchRange),
+      patchRange
+    );
+    threads.push(...this._getPortedCommentThreads(file, patchRange));
+    return threads;
   }
 
   /**
@@ -382,56 +473,19 @@
    * @param projectConfig Optional project config object to
    * include in the meta sub-object.
    */
-  getCommentsBySideForFile(
-    file: PatchSetFile,
-    patchRange: PatchRange,
-    projectConfig?: ConfigInfo
-  ): TwoSidesComments {
-    const comments = this.getCommentsBySideForPath(
-      file.path,
-      patchRange,
-      projectConfig
-    );
+  getCommentsForFile(file: PatchSetFile, patchRange: PatchRange): Comment[] {
+    const comments = this.getCommentsForPath(file.path, patchRange);
     if (file.basePath) {
-      const commentsForBasePath = this.getCommentsBySideForPath(
-        file.basePath,
-        patchRange,
-        projectConfig
-      );
-      // merge in the left and right
-      comments.left = comments.left.concat(commentsForBasePath.left);
-      comments.right = comments.right.concat(commentsForBasePath.right);
+      comments.push(...this.getCommentsForPath(file.basePath, patchRange));
     }
     return comments;
   }
 
-  /**
-   * @param comments Object keyed by file, with a value of an array
-   * of comments left on that file.
-   * @return A flattened list of all comments, where each comment
-   * also includes the file that it was left on, which was the key of the
-   * originall object.
-   */
-  _commentObjToArrayWithFile<T>(comments: {
-    [path: string]: T[];
-  }): Array<T & {__path: string}> {
-    let commentArr: Array<T & {__path: string}> = [];
-    for (const file of Object.keys(comments)) {
-      const commentsForFile: Array<T & {__path: string}> = [];
-      for (const comment of comments[file]) {
-        commentsForFile.push({...comment, __path: file});
-      }
-      commentArr = commentArr.concat(commentsForFile);
-    }
-    return commentArr;
-  }
-
   _commentObjToArray<T>(comments: {[path: string]: T[]}): T[] {
-    let commentArr: T[] = [];
-    for (const file of Object.keys(comments)) {
-      commentArr = commentArr.concat(comments[file]);
-    }
-    return commentArr;
+    return Object.keys(comments).reduce((commentArr: T[], file) => {
+      comments[file].forEach(c => commentArr.push({...c}));
+      return commentArr;
+    }, []);
   }
 
   /**
@@ -447,7 +501,7 @@
       );
     }
 
-    return this.getCommentThreads(comments).length;
+    return createCommentThreads(comments).length;
   }
 
   /**
@@ -462,6 +516,52 @@
     return this._commentObjToArray(allDrafts).length;
   }
 
+  // TODO(dhruvsri): merge with computeDraftCount
+  computePortedDraftCount(patchRange: PatchRange, path: string) {
+    const threads = this.getThreadsBySideForFile({path}, patchRange);
+    return threads.filter(thread => isDraftThread(thread) && thread.ported)
+      .length;
+  }
+
+  /**
+   * @param includeUnmodified Included unmodified status of the file in the
+   * comment string or not. For files we opt of chip instead of a string.
+   * @param filterPatchset Only count threads which belong to this patchset
+   */
+  computeCommentsString(
+    patchRange?: PatchRange,
+    path?: string,
+    changeFileInfo?: FileInfo,
+    includeUnmodified?: boolean
+  ) {
+    if (!path) return '';
+    if (!patchRange) return '';
+
+    const threads = this.getThreadsBySideForFile({path}, patchRange);
+    const commentThreadCount = threads.filter(thread => !isDraftThread(thread))
+      .length;
+    const unresolvedCount = threads.reduce((cnt, thread) => {
+      if (isUnresolved(thread)) cnt += 1;
+      return cnt;
+    }, 0);
+
+    const commentThreadString = pluralize(commentThreadCount, 'comment');
+    const unresolvedString =
+      unresolvedCount === 0 ? '' : `${unresolvedCount} unresolved`;
+
+    const unmodifiedString =
+      includeUnmodified && changeFileInfo?.status === 'U' ? 'no changes' : '';
+
+    return (
+      commentThreadString +
+      // Add a space if both comments and unresolved
+      (commentThreadString && unresolvedString ? ' ' : '') +
+      // Add parentheses around unresolved if it exists.
+      (unresolvedString ? `(${unresolvedString})` : '') +
+      (unmodifiedString ? `(${unmodifiedString})` : '')
+    );
+  }
+
   /**
    * Computes a number of unresolved comment threads in a given file and path.
    */
@@ -479,108 +579,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;
-  }
-
-  /**
-   * Whether the given comment should be included in the base side of the
-   * given patch range.
-   */
-  _isInBaseOfPatchRange(comment: CommentBasics, range: PatchRange) {
-    // If the base of the patch range is a parent of a merge, and the comment
-    // appears on a specific parent then only show the comment if the parent
-    // index of the comment matches that of the range.
-    if (comment.parent && comment.side === CommentSide.PARENT) {
-      return (
-        isMergeParent(range.basePatchNum) &&
-        comment.parent === getParentIndex(range.basePatchNum)
-      );
-    }
-
-    // If the base of the range is the parent of the patch:
-    if (
-      range.basePatchNum === ParentPatchSetNum &&
-      comment.side === CommentSide.PARENT &&
-      patchNumEquals(comment.patch_set, range.patchNum)
-    ) {
-      return true;
-    }
-    // If the base of the range is not the parent of the patch:
-    return (
-      range.basePatchNum !== ParentPatchSetNum &&
-      comment.side !== CommentSide.PARENT &&
-      patchNumEquals(comment.patch_set, range.basePatchNum)
-    );
-  }
-
-  /**
-   * Whether the given comment should be included in the revision side of the
-   * given patch range.
-   */
-  _isInRevisionOfPatchRange(comment: CommentBasics, range: PatchRange) {
-    return (
-      comment.side !== CommentSide.PARENT &&
-      patchNumEquals(comment.patch_set, range.patchNum)
-    );
-  }
-
-  /**
-   * Whether the given comment should be included in the given patch range.
-   */
-  _isInPatchRange(comment: CommentBasics, range: PatchRange): boolean {
-    return (
-      this._isInBaseOfPatchRange(comment, range) ||
-      this._isInRevisionOfPatchRange(comment, range)
-    );
+    const comments = this._commentObjToArray(this.getAllComments(true));
+    return createCommentThreads(comments);
   }
 }
 
@@ -588,12 +594,8 @@
 export const _testOnly_findCommentById =
   ChangeComments.prototype.findCommentById;
 
-export interface GrCommentApi {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
-
+export const _testOnly_getCommentsForPath =
+  ChangeComments.prototype.getCommentsForPath;
 @customElement('gr-comment-api')
 export class GrCommentApi extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
@@ -605,14 +607,21 @@
   @property({type: Object})
   _changeComments?: ChangeComments;
 
+  private readonly restApiService = appContext.restApiService;
+
+  private readonly flagsService = appContext.flagsService;
+
+  private isPortingCommentsExperimentEnabled = false;
+
   /** @override */
   created() {
     super.created();
-    this.addEventListener('reload-drafts', changeNum =>
-      // TODO(TS): This is a wrong code, however keep it as is for now
-      // If changeNum param in ChangeComments is removed, this also must be
-      // removed
-      this.reloadDrafts((changeNum as unknown) as NumericChangeId)
+  }
+
+  constructor() {
+    super();
+    this.isPortingCommentsExperimentEnabled = this.flagsService.isEnabled(
+      KnownExperimentId.PORTING_COMMENTS
     );
   }
 
@@ -621,24 +630,33 @@
    * number. The returned promise resolves when the comments have loaded, but
    * does not yield the comment data.
    */
-  loadAll(changeNum: NumericChangeId) {
-    const promises = [];
-    promises.push(this.$.restAPI.getDiffComments(changeNum));
-    promises.push(this.$.restAPI.getDiffRobotComments(changeNum));
-    promises.push(this.$.restAPI.getDiffDrafts(changeNum));
+  loadAll(changeNum: NumericChangeId, patchNum?: PatchSetNum) {
+    const revision = patchNum || CURRENT;
+    const commentsPromise = [
+      this.restApiService.getDiffComments(changeNum),
+      this.restApiService.getDiffRobotComments(changeNum),
+      this.restApiService.getDiffDrafts(changeNum),
+      this.isPortingCommentsExperimentEnabled
+        ? this.restApiService.getPortedComments(changeNum, revision)
+        : Promise.resolve({}),
+      this.isPortingCommentsExperimentEnabled
+        ? this.restApiService.getPortedDrafts(changeNum, revision)
+        : Promise.resolve({}),
+    ];
 
-    return Promise.all(promises).then(([comments, robotComments, drafts]) => {
-      this._changeComments = new ChangeComments(
-        comments,
-        // TODO(TS): Promise.all somehow resolve all types to
-        // PathToCommentsInfoMap given its PathToRobotCommentsInfoMap
-        // returned from the second promise
-        robotComments as PathToRobotCommentsInfoMap,
-        drafts,
-        changeNum
-      );
-      return this._changeComments;
-    });
+    return Promise.all(commentsPromise).then(
+      ([comments, robotComments, drafts, portedComments, portedDrafts]) => {
+        this._changeComments = new ChangeComments(
+          comments,
+          // TS 4.0.5 fails without 'as'
+          robotComments as PathToRobotCommentsInfoMap | undefined,
+          drafts,
+          portedComments,
+          portedDrafts
+        );
+        return this._changeComments;
+      }
+    );
   }
 
   /**
@@ -650,17 +668,30 @@
     if (!this._changeComments) {
       return this.loadAll(changeNum);
     }
-    const oldChangeComments = this._changeComments;
-    return this.$.restAPI.getDiffDrafts(changeNum).then(drafts => {
-      this._changeComments = new ChangeComments(
-        oldChangeComments.comments,
-        (oldChangeComments.robotComments as unknown) as PathToRobotCommentsInfoMap,
-        drafts,
-        changeNum
+    return this.restApiService.getDiffDrafts(changeNum).then(drafts => {
+      this._changeComments = this._changeComments!.cloneWithUpdatedDrafts(
+        drafts
       );
       return this._changeComments;
     });
   }
+
+  reloadPortedComments(changeNum: NumericChangeId, patchNum: PatchSetNum) {
+    if (!this._changeComments) {
+      this.loadAll(changeNum);
+      return Promise.resolve();
+    }
+    return Promise.all([
+      this.restApiService.getPortedComments(changeNum, patchNum),
+      this.restApiService.getPortedDrafts(changeNum, patchNum),
+    ]).then(res => {
+      if (!this._changeComments) return;
+      this._changeComments = this._changeComments.cloneWithUpdatedPortedComments(
+        res[0],
+        res[1]
+      );
+    });
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_html.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_html.ts
index 91d8b41..1489006 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_html.ts
@@ -16,6 +16,4 @@
  */
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
-export const htmlTemplate = html`
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
+export const htmlTemplate = html``;
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 be6f646..94ec78f 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,10 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-comment-api.js';
 import {ChangeComments} from './gr-comment-api.js';
+import {isInRevisionOfPatchRange, isInBaseOfPatchRange, isDraftThread, isUnresolved, createCommentThreads} from '../../../utils/comment-util.js';
+import {createDraft, createComment, createChangeComments, createCommentThread} from '../../../test/test-data-generators.js';
+import {CommentSide, Side} from '../../../constants/constants.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-comment-api');
 
@@ -33,24 +37,20 @@
   test('loads logged-out', () => {
     const changeNum = 1234;
 
-    sinon.stub(element.$.restAPI, 'getLoggedIn')
-        .returns(Promise.resolve(false));
-    sinon.stub(element.$.restAPI, 'getDiffComments')
-        .returns(Promise.resolve({
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    const diffCommentsSpy = stubRestApi('getDiffComments').returns(
+        Promise.resolve({
           'foo.c': [{id: '123', message: 'foo bar', in_reply_to: '321'}],
         }));
-    sinon.stub(element.$.restAPI, 'getDiffRobotComments')
-        .returns(Promise.resolve({'foo.c': [{id: '321', message: 'done'}]}));
-    sinon.stub(element.$.restAPI, 'getDiffDrafts')
-        .returns(Promise.resolve({}));
+    const diffRobotCommentsSpy = stubRestApi('getDiffRobotComments').returns(
+        Promise.resolve({'foo.c': [{id: '321', message: 'done'}]}));
+    const diffDraftsSpy = stubRestApi('getDiffDrafts').returns(
+        Promise.resolve({}));
 
     return element.loadAll(changeNum).then(() => {
-      assert.isTrue(element.$.restAPI.getDiffComments.calledWithExactly(
-          changeNum));
-      assert.isTrue(element.$.restAPI.getDiffRobotComments.calledWithExactly(
-          changeNum));
-      assert.isTrue(element.$.restAPI.getDiffDrafts.calledWithExactly(
-          changeNum));
+      assert.isTrue(diffCommentsSpy.calledWithExactly(changeNum));
+      assert.isTrue(diffRobotCommentsSpy.calledWithExactly(changeNum));
+      assert.isTrue(diffDraftsSpy.calledWithExactly(changeNum));
       assert.isOk(element._changeComments._comments);
       assert.isOk(element._changeComments._robotComments);
       assert.deepEqual(element._changeComments._drafts, {});
@@ -60,24 +60,21 @@
   test('loads logged-in', () => {
     const changeNum = 1234;
 
-    sinon.stub(element.$.restAPI, 'getLoggedIn')
-        .returns(Promise.resolve(true));
-    sinon.stub(element.$.restAPI, 'getDiffComments')
-        .returns(Promise.resolve({
+    stubRestApi('getLoggedIn').returns(Promise.resolve(true));
+    const getCommentsStub = stubRestApi('getDiffComments').returns(
+        Promise.resolve({
           'foo.c': [{id: '123', message: 'foo bar', in_reply_to: '321'}],
-        }));
-    sinon.stub(element.$.restAPI, 'getDiffRobotComments')
+        })
+    );
+    const getRobotCommentsStub = stubRestApi('getDiffRobotComments')
         .returns(Promise.resolve({'foo.c': [{id: '321', message: 'done'}]}));
-    sinon.stub(element.$.restAPI, 'getDiffDrafts')
+    const getDraftsStub = stubRestApi('getDiffDrafts')
         .returns(Promise.resolve({'foo.c': [{id: '555', message: 'ack'}]}));
 
     return element.loadAll(changeNum).then(() => {
-      assert.isTrue(element.$.restAPI.getDiffComments.calledWithExactly(
-          changeNum));
-      assert.isTrue(element.$.restAPI.getDiffRobotComments.calledWithExactly(
-          changeNum));
-      assert.isTrue(element.$.restAPI.getDiffDrafts.calledWithExactly(
-          changeNum));
+      assert.isTrue(getCommentsStub.calledWithExactly(changeNum));
+      assert.isTrue(getRobotCommentsStub.calledWithExactly(changeNum));
+      assert.isTrue(getDraftsStub.calledWithExactly(changeNum));
       assert.isOk(element._changeComments._comments);
       assert.isOk(element._changeComments._robotComments);
       assert.notDeepEqual(element._changeComments._drafts, {});
@@ -89,11 +86,11 @@
     let robotCommentStub;
     let draftStub;
     setup(() => {
-      commentStub = sinon.stub(element.$.restAPI, 'getDiffComments')
+      commentStub = stubRestApi('getDiffComments')
           .returns(Promise.resolve({}));
-      robotCommentStub = sinon.stub(element.$.restAPI,
+      robotCommentStub = stubRestApi(
           'getDiffRobotComments').returns(Promise.resolve({}));
-      draftStub = sinon.stub(element.$.restAPI, 'getDiffDrafts')
+      draftStub = stubRestApi('getDiffDrafts')
           .returns(Promise.resolve({}));
     });
 
@@ -133,190 +130,488 @@
   suite('_changeComment methods', () => {
     setup(done => {
       const changeNum = 1234;
-      stub('gr-rest-api-interface', {
-        getDiffComments() { return Promise.resolve({}); },
-        getDiffRobotComments() { return Promise.resolve({}); },
-        getDiffDrafts() { return Promise.resolve({}); },
-      });
+      stubRestApi('getDiffComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
       element.loadAll(changeNum).then(() => {
         done();
       });
     });
 
+    suite('ported comments', () => {
+      let portedComments;
+      let changeComments;
+      const comment1 = {
+        ...createComment(),
+        unresolved: true,
+        id: '1',
+        line: 136,
+        patch_set: 2,
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 1,
+          end_character: 1,
+        },
+      };
+
+      const comment2 = {
+        ...createComment(),
+        patch_set: 2,
+        id: '2',
+        line: 5,
+      };
+
+      const comment3 = {
+        ...createComment(),
+        side: CommentSide.PARENT,
+        line: 10,
+        unresolved: true,
+      };
+
+      const comment4 = {
+        ...comment3,
+        parent: -2,
+      };
+
+      const draft1 = {
+        ...createDraft(),
+        id: 'db977012_e1f13828',
+        line: 4,
+        patch_set: 2,
+      };
+      const draft2 = {
+        ...createDraft(),
+        id: '503008e2_0ab203ee',
+        line: 11,
+        unresolved: true,
+        // slightly larger timestamp so it's sorted higher
+        updated: '2018-02-13 22:49:48.018000001',
+        patch_set: 2,
+      };
+
+      setup(() => {
+        portedComments = {
+          'karma.conf.js': [{
+            ...comment1,
+            patch_set: 4,
+            range: {
+              start_line: 136,
+              start_character: 16,
+              end_line: 136,
+              end_character: 29,
+            },
+          }],
+        };
+
+        changeComments = new ChangeComments(
+            {/* comments */
+              'karma.conf.js': [
+                // resolved comment that will not be ported over
+                comment2,
+                // original comment that will be ported over to patchset 4
+                comment1,
+              ],
+            },
+            {}/* robot comments */,
+            {}/* drafts */,
+            portedComments,
+            {}/* ported drafts */
+        );
+      });
+
+      test('threads containing ported comment are returned', () => {
+        assert.equal(changeComments.getAllThreadsForChange().length,
+            2);
+
+        const portedThreads = changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 'PARENT'});
+
+        assert.equal(portedThreads.length, 1);
+        // check range of thread is from the ported comment and not the original
+        assert.deepEqual(portedThreads[0].range, {
+          start_line: 136,
+          start_character: 16,
+          end_line: 136,
+          end_character: 29,
+        });
+
+        // thread ported over if comparing patchset 1 vs patchset 4
+        assert.equal(changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 1}
+        ).length, 1);
+
+        // verify ported thread is not returned if original thread will be
+        // shown
+        // original thread attached to right side
+        assert.equal(changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 2, basePatchNum: 'PARENT'}
+        ).length, 0);
+        assert.equal(changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 2, basePatchNum: 1}
+        ).length, 0);
+
+        // original thread attached to left side
+        assert.equal(changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 3, basePatchNum: 2}
+        ).length, 0);
+      });
+
+      test('threads without any ported comment are filtered out', () => {
+        changeComments = new ChangeComments(
+            {/* comments */
+              // comment that is not ported over
+              'karma.conf.js': [comment2],
+            },
+            {}/* robot comments */,
+            {/* drafts */
+              'karma.conf.js': [draft2],
+            },
+            // comment1 that is ported over but does not have any thread
+            // that has a comment that matches it
+            portedComments,
+            {}/* ported drafts */
+        );
+
+        assert.equal(createCommentThreads(changeComments
+            .getAllCommentsForPath('karma.conf.js')).length, 1);
+        assert.equal(changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 'PARENT'}
+        ).length, 0);
+      });
+
+      test('comments with side=PARENT are ported over', () => {
+        changeComments = new ChangeComments(
+            {/* comments */
+              // comment left on Base
+              'karma.conf.js': [comment3],
+            },
+            {}/* robot comments */,
+            {/* drafts */
+              'karma.conf.js': [draft2],
+            },
+            {/* ported comments */
+              'karma.conf.js': [{
+                ...comment3,
+                line: 31,
+                patch_set: 4,
+              }],
+            },
+            {}/* ported drafts */
+        );
+
+        const portedThreads = changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 'PARENT'});
+        assert.equal(portedThreads.length, 1);
+        assert.equal(portedThreads[0].line, 31);
+        assert.equal(portedThreads[0].diffSide, Side.LEFT);
+
+        assert.equal(changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: -2}
+        ).length, 0);
+
+        assert.equal(changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 2}
+        ).length, 0);
+      });
+
+      test('comments left on merge parent is not ported over', () => {
+        changeComments = new ChangeComments(
+            {/* comments */
+              // comment left on Base
+              'karma.conf.js': [comment4],
+            },
+            {}/* robot comments */,
+            {/* drafts */
+              'karma.conf.js': [draft2],
+            },
+            {/* ported comments */
+              'karma.conf.js': [{
+                ...comment4,
+                line: 31,
+                patch_set: 4,
+              }],
+            },
+            {}/* ported drafts */
+        );
+
+        const portedThreads = changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 'PARENT'});
+        assert.equal(portedThreads.length, 0);
+
+        assert.equal(changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: -2}
+        ).length, 0);
+
+        assert.equal(changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 2}
+        ).length, 0);
+      });
+
+      test('ported comments contribute to comment count', () => {
+        assert.equal(changeComments.computeCommentsString(
+            {basePatchNum: 'PARENT', patchNum: 2}, 'karma.conf.js',
+            {__path: 'karma.conf.js'}), '2 comments (1 unresolved)');
+
+        // comment1 is ported over to patchset 4
+        assert.equal(changeComments.computeCommentsString(
+            {basePatchNum: 'PARENT', patchNum: 4}, 'karma.conf.js',
+            {__path: 'karma.conf.js'}), '1 comment (1 unresolved)');
+      });
+
+      test('drafts are ported over', () => {
+        changeComments = new ChangeComments(
+            {}/* comments */,
+            {}/* robotComments */,
+            {/* drafts */
+              // draft1: resolved draft that will be ported over to ps 4
+              // draft2: unresolved draft that will be ported over to ps 4
+              'karma.conf.js': [draft1, draft2],
+            },
+            {}/* ported comments */,
+            {/* ported drafts */
+              'karma.conf.js': [
+                {
+                  ...draft1,
+                  line: 5,
+                  patch_set: 4,
+                },
+                {
+                  ...draft2,
+                  line: 31,
+                  patch_set: 4,
+                },
+              ],
+            }
+        );
+
+        const portedThreads = changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 'PARENT'});
+
+        // resolved draft is ported over
+        assert.equal(portedThreads.length, 2);
+        assert.equal(portedThreads[0].line, 5);
+        assert.isTrue(isDraftThread(portedThreads[0]));
+        assert.isFalse(isUnresolved(portedThreads[0]));
+
+        // unresolved draft is ported over
+        assert.equal(portedThreads[1].line, 31);
+        assert.isTrue(isDraftThread(portedThreads[1]));
+        assert.isTrue(isUnresolved(portedThreads[1]));
+
+        assert.equal(createCommentThreads(
+            changeComments.getAllCommentsForPath('karma.conf.js'),
+            {patchNum: 4, basePatchNum: 'PARENT'}).length, 0);
+      });
+    });
+
     test('_isInBaseOfPatchRange', () => {
       const comment = {patch_set: 1};
       const patchRange = {basePatchNum: 1, patchNum: 2};
-      assert.isTrue(element._changeComments._isInBaseOfPatchRange(comment,
+      assert.isTrue(isInBaseOfPatchRange(comment,
           patchRange));
 
       patchRange.basePatchNum = PARENT;
-      assert.isFalse(element._changeComments._isInBaseOfPatchRange(comment,
+      assert.isFalse(isInBaseOfPatchRange(comment,
           patchRange));
 
       comment.side = PARENT;
-      assert.isFalse(element._changeComments._isInBaseOfPatchRange(comment,
+      assert.isFalse(isInBaseOfPatchRange(comment,
           patchRange));
 
       comment.patch_set = 2;
-      assert.isTrue(element._changeComments._isInBaseOfPatchRange(comment,
+      assert.isTrue(isInBaseOfPatchRange(comment,
           patchRange));
 
       patchRange.basePatchNum = -2;
       comment.side = PARENT;
       comment.parent = 1;
-      assert.isFalse(element._changeComments._isInBaseOfPatchRange(comment,
+      assert.isFalse(isInBaseOfPatchRange(comment,
           patchRange));
 
       comment.parent = 2;
-      assert.isTrue(element._changeComments._isInBaseOfPatchRange(comment,
+      assert.isTrue(isInBaseOfPatchRange(comment,
           patchRange));
     });
 
-    test('_isInRevisionOfPatchRange', () => {
+    test('isInRevisionOfPatchRange', () => {
       const comment = {patch_set: 123};
       const patchRange = {basePatchNum: 122, patchNum: 124};
-      assert.isFalse(element._changeComments._isInRevisionOfPatchRange(
+      assert.isFalse(isInRevisionOfPatchRange(
           comment, patchRange));
 
       patchRange.patchNum = 123;
-      assert.isTrue(element._changeComments._isInRevisionOfPatchRange(
+      assert.isTrue(isInRevisionOfPatchRange(
           comment, patchRange));
 
       comment.side = PARENT;
-      assert.isFalse(element._changeComments._isInRevisionOfPatchRange(
+      assert.isFalse(isInRevisionOfPatchRange(
           comment, patchRange));
     });
 
-    test('_isInPatchRange', () => {
-      const patchRange1 = {basePatchNum: 122, patchNum: 124};
-      const patchRange2 = {basePatchNum: 123, patchNum: 125};
-      const patchRange3 = {basePatchNum: 124, patchNum: 125};
-
-      const isInBasePatchStub = sinon.stub(element._changeComments,
-          '_isInBaseOfPatchRange');
-      const isInRevisionPatchStub = sinon.stub(element._changeComments,
-          '_isInRevisionOfPatchRange');
-
-      isInBasePatchStub.withArgs({}, patchRange1).returns(true);
-      isInBasePatchStub.withArgs({}, patchRange2).returns(false);
-      isInBasePatchStub.withArgs({}, patchRange3).returns(false);
-
-      isInRevisionPatchStub.withArgs({}, patchRange1).returns(false);
-      isInRevisionPatchStub.withArgs({}, patchRange2).returns(true);
-      isInRevisionPatchStub.withArgs({}, patchRange3).returns(false);
-
-      assert.isTrue(element._changeComments._isInPatchRange({}, patchRange1));
-      assert.isTrue(element._changeComments._isInPatchRange({}, patchRange2));
-      assert.isFalse(element._changeComments._isInPatchRange({},
-          patchRange3));
-    });
-
     suite('comment ranges and paths', () => {
+      const commentObjs = {};
       function makeTime(mins) {
         return `2013-02-26 15:0${mins}:43.986000000`;
       }
 
       setup(() => {
+        commentObjs['01'] = {
+          ...createComment(),
+          id: '01',
+          patch_set: 2,
+          side: PARENT,
+          line: 1,
+          updated: makeTime(1),
+          range: {
+            start_line: 1,
+            start_character: 2,
+            end_line: 2,
+            end_character: 2,
+          },
+        };
+
+        commentObjs['02'] = {
+          ...createComment(),
+          id: '02',
+          in_reply_to: '04',
+          patch_set: 2,
+          unresolved: true,
+          line: 1,
+          updated: makeTime(3),
+        };
+
+        commentObjs['03'] = {
+          ...createComment(),
+          id: '03',
+          patch_set: 2,
+          side: PARENT,
+          line: 2,
+          updated: makeTime(1),
+        };
+
+        commentObjs['04'] = {
+          ...createComment(),
+          id: '04',
+          patch_set: 2,
+          line: 1,
+          updated: makeTime(1),
+        };
+
+        commentObjs['05'] = {
+          ...createComment(),
+          id: '05',
+          patch_set: 2,
+          line: 2,
+          updated: makeTime(1),
+        };
+
+        commentObjs['06'] = {
+          ...createComment(),
+          id: '06',
+          patch_set: 3,
+          line: 2,
+          updated: makeTime(1),
+        };
+
+        commentObjs['07'] = {
+          ...createComment(),
+          id: '07',
+          patch_set: 2,
+          side: PARENT,
+          unresolved: false,
+          line: 1,
+          updated: makeTime(1),
+        };
+
+        commentObjs['08'] = {
+          ...createComment(),
+          id: '08',
+          patch_set: 2,
+          side: PARENT,
+          unresolved: true,
+          in_reply_to: '07',
+          line: 1,
+          updated: makeTime(1),
+        };
+
+        commentObjs['09'] = {
+          ...createComment(),
+          id: '09',
+          patch_set: 3,
+          line: 1,
+          updated: makeTime(1),
+        };
+
+        commentObjs['10'] = {
+          ...createComment(),
+          id: '10',
+          patch_set: 5,
+          side: PARENT,
+          line: 1,
+          updated: makeTime(1),
+        };
+
+        commentObjs['11'] = {
+          ...createComment(),
+          id: '11',
+          patch_set: 5,
+          line: 1,
+          updated: makeTime(1),
+        };
+
+        commentObjs['12'] = {
+          ...createDraft(),
+          id: '12',
+          patch_set: 2,
+          side: PARENT,
+          line: 1,
+          updated: makeTime(3),
+        };
+
+        commentObjs['13'] = {
+          ...createDraft(),
+          id: '13',
+          in_reply_to: '04',
+          patch_set: 2,
+          line: 1,
+          // Draft gets lower timestamp than published comment, because we
+          // want to test that the draft still gets sorted to the end.
+          updated: makeTime(2),
+        };
+
+        commentObjs['14'] = {
+          ...createDraft(),
+          id: '14',
+          patch_set: 3,
+          line: 1,
+          path: 'file/two',
+          updated: makeTime(3),
+        };
+
         const drafts = {
           'file/one': [
-            {
-              id: '12',
-              patch_set: 2,
-              side: PARENT,
-              line: 1,
-              updated: makeTime(3),
-            },
-            {
-              id: '13',
-              in_reply_to: '04',
-              patch_set: 2,
-              line: 1,
-              // Draft gets lower timestamp than published comment, because we
-              // want to test that the draft still gets sorted to the end.
-              updated: makeTime(2),
-            },
+            commentObjs['12'],
+            commentObjs['13'],
           ],
           'file/two': [
-            {
-              id: '05',
-              patch_set: 3,
-              line: 1,
-              updated: makeTime(3),
-            },
+            commentObjs['14'],
           ],
         };
         const robotComments = {
           'file/one': [
-            {
-              id: '01',
-              patch_set: 2,
-              side: PARENT,
-              line: 1,
-              updated: makeTime(1),
-              range: {
-                start_line: 1,
-                start_character: 2,
-                end_line: 2,
-                end_character: 2,
-              },
-            }, {
-              id: '02',
-              in_reply_to: '04',
-              patch_set: 2,
-              unresolved: true,
-              line: 1,
-              updated: makeTime(3),
-            },
+            commentObjs['01'], commentObjs['02'],
           ],
         };
         const comments = {
-          'file/one': [
-            {
-              id: '03',
-              patch_set: 2,
-              side: PARENT,
-              line: 2,
-              updated: makeTime(1),
-            },
-            {id: '04', patch_set: 2, line: 1, updated: makeTime(1)},
-          ],
-          'file/two': [
-            {id: '05', patch_set: 2, line: 2, updated: makeTime(1)},
-            {id: '06', patch_set: 3, line: 2, updated: makeTime(1)},
-          ],
-          'file/three': [
-            {
-              id: '07',
-              patch_set: 2,
-              side: PARENT,
-              unresolved: false,
-              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: '10',
-              patch_set: 5,
-              side: PARENT,
-              line: 1,
-              updated: makeTime(1),
-            },
-            {id: '11', patch_set: 5, line: 1, updated: makeTime(1)},
-          ],
+          'file/one': [commentObjs['03'], commentObjs['04']],
+          'file/two': [commentObjs['05'], commentObjs['06']],
+          'file/three': [commentObjs['07'], commentObjs['08'],
+            commentObjs['09']],
+          'file/four': [commentObjs['10'], commentObjs['11']],
         };
         element._changeComments =
-            new ChangeComments(comments, robotComments, drafts, 1234);
+            new ChangeComments(comments, robotComments, drafts, {}, {});
       });
 
       test('getPaths', () => {
@@ -346,33 +641,40 @@
         assert.property(paths, 'file/four');
       });
 
-      test('getCommentsBySideForPath', () => {
+      test('getCommentsForPath', () => {
         const patchRange = {basePatchNum: 1, patchNum: 3};
         let path = 'file/one';
-        let comments = element._changeComments.getCommentsBySideForPath(path,
+        let comments = element._changeComments.getCommentsForPath(path,
             patchRange);
-        assert.equal(comments.meta.changeNum, 1234);
-        assert.equal(comments.left.length, 0);
-        assert.equal(comments.right.length, 0);
+        assert.equal(comments.filter(c => isInBaseOfPatchRange(c, patchRange))
+            .length, 0);
+        assert.equal(comments.filter(c => isInRevisionOfPatchRange(c,
+            patchRange)).length, 0);
 
         path = 'file/two';
-        comments = element._changeComments.getCommentsBySideForPath(path,
+        comments = element._changeComments.getCommentsForPath(path,
             patchRange);
-        assert.equal(comments.left.length, 0);
-        assert.equal(comments.right.length, 2);
+        assert.equal(comments.filter(c => isInBaseOfPatchRange(c, patchRange))
+            .length, 0);
+        assert.equal(comments.filter(c => isInRevisionOfPatchRange(c,
+            patchRange)).length, 2);
 
         patchRange.basePatchNum = 2;
-        comments = element._changeComments.getCommentsBySideForPath(path,
+        comments = element._changeComments.getCommentsForPath(path,
             patchRange);
-        assert.equal(comments.left.length, 1);
-        assert.equal(comments.right.length, 2);
+        assert.equal(comments.filter(c => isInBaseOfPatchRange(c,
+            patchRange)).length, 1);
+        assert.equal(comments.filter(c => isInRevisionOfPatchRange(c,
+            patchRange)).length, 2);
 
         patchRange.basePatchNum = PARENT;
         path = 'file/three';
-        comments = element._changeComments.getCommentsBySideForPath(path,
+        comments = element._changeComments.getCommentsForPath(path,
             patchRange);
-        assert.equal(comments.left.length, 0);
-        assert.equal(comments.right.length, 1);
+        assert.equal(comments.filter(c => isInBaseOfPatchRange(c, patchRange))
+            .length, 0);
+        assert.equal(comments.filter(c => isInRevisionOfPatchRange(c,
+            patchRange)).length, 1);
       });
 
       test('getAllCommentsForPath', () => {
@@ -448,6 +750,78 @@
             element._changeComments.computeUnresolvedNum(1, 'path'), 0);
       });
 
+      test('computeCommentsString', () => {
+        const changeComments = createChangeComments();
+        const parentTo1 = {
+          basePatchNum: 'PARENT',
+          patchNum: 1,
+        };
+        const parentTo2 = {
+          basePatchNum: 'PARENT',
+          patchNum: 2,
+        };
+        const _1To2 = {
+          basePatchNum: 1,
+          patchNum: 2,
+        };
+
+        assert.equal(
+            changeComments.computeCommentsString(parentTo1, '/COMMIT_MSG',
+                {__path: '/COMMIT_MSG'}), '2 comments (1 unresolved)');
+        assert.equal(
+            changeComments.computeCommentsString(parentTo1, '/COMMIT_MSG',
+                {__path: '/COMMIT_MSG', status: 'U'}, true),
+            '2 comments (1 unresolved)(no changes)');
+        assert.equal(
+            changeComments.computeCommentsString(_1To2, '/COMMIT_MSG',
+                {__path: '/COMMIT_MSG'}), '3 comments (1 unresolved)');
+
+        assert.equal(
+            changeComments.computeCommentsString(parentTo1, 'myfile.txt',
+                {__path: 'myfile.txt'}), '1 comment');
+        assert.equal(
+            changeComments.computeCommentsString(_1To2, 'myfile.txt',
+                {__path: 'myfile.txt'}), '3 comments');
+
+        assert.equal(
+            changeComments.computeCommentsString(parentTo1,
+                'file_added_in_rev2.txt',
+                {__path: 'file_added_in_rev2.txt'}), '');
+        assert.equal(
+            changeComments.computeCommentsString(_1To2,
+                'file_added_in_rev2.txt',
+                {__path: 'file_added_in_rev2.txt'}), '');
+
+        assert.equal(
+            changeComments.computeCommentsString(parentTo2, '/COMMIT_MSG',
+                {__path: '/COMMIT_MSG'}), '1 comment');
+        assert.equal(
+            changeComments.computeCommentsString(_1To2, '/COMMIT_MSG',
+                {__path: '/COMMIT_MSG'}), '3 comments (1 unresolved)');
+
+        assert.equal(
+            changeComments.computeCommentsString(parentTo2, 'myfile.txt',
+                {__path: 'myfile.txt'}), '2 comments');
+        assert.equal(
+            changeComments.computeCommentsString(_1To2, 'myfile.txt',
+                {__path: 'myfile.txt'}), '3 comments');
+
+        assert.equal(
+            changeComments.computeCommentsString(parentTo2,
+                'file_added_in_rev2.txt',
+                {__path: 'file_added_in_rev2.txt'}), '');
+        assert.equal(
+            changeComments.computeCommentsString(_1To2,
+                'file_added_in_rev2.txt',
+                {__path: 'file_added_in_rev2.txt'}), '');
+        assert.equal(
+            changeComments.computeCommentsString(parentTo2, 'unresolved.file',
+                {__path: 'unresolved.file'}), '2 comments (1 unresolved)');
+        assert.equal(
+            changeComments.computeCommentsString(_1To2, 'unresolved.file',
+                {__path: 'unresolved.file'}), '2 comments (1 unresolved)');
+      });
+
       test('computeCommentThreadCount', () => {
         assert.equal(element._changeComments
             .computeCommentThreadCount({
@@ -521,220 +895,31 @@
       test('computeAllThreads', () => {
         const expectedThreads = [
           {
-            comments: [
-              {
-                id: '01',
-                patch_set: 2,
-                side: 'PARENT',
-                line: 1,
-                updated: '2013-02-26 15:01:43.986000000',
-                range: {
-                  start_line: 1,
-                  start_character: 2,
-                  end_line: 2,
-                  end_character: 2,
-                },
-                path: 'file/one',
-                __path: 'file/one',
-              },
-            ],
-            commentSide: 'PARENT',
-            patchNum: 2,
-            path: 'file/one',
-            line: 1,
-            rootId: '01',
+            ...createCommentThread([{...commentObjs['01'], path: 'file/one'}]),
           }, {
-            comments: [
-              {
-                id: '03',
-                patch_set: 2,
-                side: 'PARENT',
-                line: 2,
-                path: 'file/one',
-                __path: 'file/one',
-                updated: '2013-02-26 15:01:43.986000000',
-              },
-            ],
-            commentSide: 'PARENT',
-            patchNum: 2,
-            path: 'file/one',
-            line: 2,
-            rootId: '03',
+            ...createCommentThread([{...commentObjs['03'], path: 'file/one'}]),
           }, {
-            comments: [
-              {
-                id: '04',
-                patch_set: 2,
-                line: 1,
-                path: 'file/one',
-                __path: 'file/one',
-                updated: '2013-02-26 15:01:43.986000000',
-              },
-              {
-                id: '02',
-                in_reply_to: '04',
-                patch_set: 2,
-                unresolved: true,
-                line: 1,
-                path: 'file/one',
-                __path: 'file/one',
-                updated: '2013-02-26 15:03:43.986000000',
-              },
-              {
-                id: '13',
-                in_reply_to: '04',
-                patch_set: 2,
-                line: 1,
-                path: 'file/one',
-                __path: 'file/one',
-                __draft: true,
-                updated: '2013-02-26 15:02:43.986000000',
-              },
-            ],
-            patchNum: 2,
-            path: 'file/one',
-            line: 1,
-            rootId: '04',
+            ...createCommentThread([{...commentObjs['04'], path: 'file/one'},
+              {...commentObjs['02'], path: 'file/one'},
+              {...commentObjs['13'], path: 'file/one'}]),
           }, {
-            comments: [
-              {
-                id: '05',
-                patch_set: 2,
-                line: 2,
-                path: 'file/two',
-                __path: 'file/two',
-                updated: '2013-02-26 15:01:43.986000000',
-              },
-            ],
-            patchNum: 2,
-            path: 'file/two',
-            line: 2,
-            rootId: '05',
+            ...createCommentThread([{...commentObjs['05'], path: 'file/two'}]),
           }, {
-            comments: [
-              {
-                id: '06',
-                patch_set: 3,
-                line: 2,
-                path: 'file/two',
-                __path: 'file/two',
-                updated: '2013-02-26 15:01:43.986000000',
-              },
-            ],
-            patchNum: 3,
-            path: 'file/two',
-            line: 2,
-            rootId: '06',
+            ...createCommentThread([{...commentObjs['06'], path: 'file/two'}]),
           }, {
-            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',
-                __path: 'file/three',
-                updated: '2013-02-26 15:01:43.986000000',
-              },
-            ],
-            commentSide: 'PARENT',
-            patchNum: 2,
-            path: 'file/three',
-            line: 1,
-            rootId: '07',
+            ...createCommentThread([{...commentObjs['07'], path: 'file/three'},
+              {...commentObjs['08'], path: 'file/three'}]),
           }, {
-            comments: [
-              {
-                id: '09',
-                patch_set: 3,
-                line: 1,
-                path: 'file/three',
-                __path: 'file/three',
-                updated: '2013-02-26 15:01:43.986000000',
-              },
-            ],
-            patchNum: 3,
-            path: 'file/three',
-            line: 1,
-            rootId: '09',
+            ...createCommentThread([{...commentObjs['09'], path: 'file/three'}]
+            ),
           }, {
-            comments: [
-              {
-                id: '10',
-                patch_set: 5,
-                side: 'PARENT',
-                line: 1,
-                path: 'file/four',
-                __path: 'file/four',
-                updated: '2013-02-26 15:01:43.986000000',
-              },
-            ],
-            commentSide: 'PARENT',
-            patchNum: 5,
-            path: 'file/four',
-            line: 1,
-            rootId: '10',
+            ...createCommentThread([{...commentObjs['10'], path: 'file/four'}]),
           }, {
-            comments: [
-              {
-                id: '11',
-                patch_set: 5,
-                line: 1,
-                path: 'file/four',
-                __path: 'file/four',
-                updated: '2013-02-26 15:01:43.986000000',
-              },
-            ],
-            rootId: '11',
-            patchNum: 5,
-            path: 'file/four',
-            line: 1,
+            ...createCommentThread([{...commentObjs['11'], path: 'file/four'}]),
           }, {
-            comments: [
-              {
-                id: '05',
-                patch_set: 3,
-                line: 1,
-                path: 'file/two',
-                __path: 'file/two',
-                __draft: true,
-                updated: '2013-02-26 15:03:43.986000000',
-              },
-            ],
-            rootId: '05',
-            patchNum: 3,
-            path: 'file/two',
-            line: 1,
+            ...createCommentThread([{...commentObjs['12'], path: 'file/one'}]),
           }, {
-            comments: [
-              {
-                id: '12',
-                patch_set: 2,
-                side: 'PARENT',
-                line: 1,
-                path: 'file/one',
-                __path: 'file/one',
-                __draft: true,
-                updated: '2013-02-26 15:03:43.986000000',
-              },
-            ],
-            rootId: '12',
-            commentSide: 'PARENT',
-            patchNum: 2,
-            path: 'file/one',
-            line: 1,
+            ...createCommentThread([{...commentObjs['14'], path: 'file/two'}]),
           },
         ];
         const threads = element._changeComments.getAllThreadsForChange();
@@ -743,48 +928,14 @@
 
       test('getCommentsForThreadGroup', () => {
         let expectedComments = [
-          {
-            __path: 'file/one',
-            path: 'file/one',
-            id: '04',
-            patch_set: 2,
-            line: 1,
-            updated: '2013-02-26 15:01:43.986000000',
-          },
-          {
-            __path: 'file/one',
-            path: 'file/one',
-            id: '02',
-            in_reply_to: '04',
-            patch_set: 2,
-            unresolved: true,
-            line: 1,
-            updated: '2013-02-26 15:03:43.986000000',
-          },
-          {
-            __path: 'file/one',
-            path: 'file/one',
-            __draft: true,
-            id: '13',
-            in_reply_to: '04',
-            patch_set: 2,
-            line: 1,
-            updated: '2013-02-26 15:02:43.986000000',
-          },
+          {...commentObjs['04'], path: 'file/one'},
+          {...commentObjs['02'], path: 'file/one'},
+          {...commentObjs['13'], path: 'file/one'},
         ];
         assert.deepEqual(element._changeComments.getCommentsForThread('04'),
             expectedComments);
 
-        expectedComments = [{
-          id: '12',
-          patch_set: 2,
-          side: 'PARENT',
-          line: 1,
-          path: 'file/one',
-          __path: 'file/one',
-          __draft: true,
-          updated: '2013-02-26 15:03:43.986000000',
-        }];
+        expectedComments = [{...commentObjs['12'], path: 'file/one'}];
 
         assert.deepEqual(element._changeComments.getCommentsForThread('12'),
             expectedComments);
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 6d81e9b..de68554 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,
@@ -47,16 +43,13 @@
   GrRangedCommentLayer,
 } from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
 import {GrCoverageLayer} from '../gr-coverage-layer/gr-coverage-layer';
+import {DiffViewMode} from '../../../api/diff';
 import {Side} from '../../../constants/constants';
 import {GrDiffLine, LineNumber} from '../gr-diff/gr-diff-line';
 import {GrDiffGroup} from '../gr-diff/gr-diff-group';
 import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
 import {getLineNumber} from '../gr-diff/gr-diff-utils';
-
-const DiffViewMode = {
-  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-  UNIFIED: 'UNIFIED_DIFF',
-};
+import {fireAlert, fireEvent} from '../../../utils/event-util';
 
 const TRAILING_WHITESPACE_PATTERN = /\s+$/;
 
@@ -150,9 +143,6 @@
   @property({type: Array})
   coverageRanges: CoverageRange[] = [];
 
-  @property({type: Boolean})
-  useNewContextControls = false;
-
   @property({
     type: Array,
     computed: '_computeLeftCoverageRanges(coverageRanges)',
@@ -222,17 +212,13 @@
 
     const isBinary = !!(this.isImageDiff || this.diff.binary);
 
-    this.dispatchEvent(
-      new CustomEvent('render-start', {bubbles: true, composed: true})
-    );
+    fireEvent(this, 'render-start');
     this._cancelableRenderPromise = util.makeCancelable(
       this.$.processor.process(this.diff.content, isBinary).then(() => {
         if (this.isImageDiff) {
           (this._builder as GrDiffBuilderImage).renderDiff();
         }
-        this.dispatchEvent(
-          new CustomEvent('render-content', {bubbles: true, composed: true})
-        );
+        fireEvent(this, 'render-content');
       })
     );
     return (
@@ -309,7 +295,7 @@
     return this.getContentTdByLine(line, side, row);
   }
 
-  getLineElByNumber(lineNumber: string | number, side?: Side) {
+  getLineElByNumber(lineNumber: LineNumber, side?: Side) {
     const sideSelector = side ? '.' + side : '';
     return this.diffElement.querySelector(
       `.lineNum[data-value="${lineNumber}"]${sideSelector}`
@@ -339,16 +325,7 @@
       sectionEl.parentNode.removeChild(sectionEl);
     }
 
-    this.async(
-      () =>
-        this.dispatchEvent(
-          new CustomEvent('render-content', {
-            composed: true,
-            bubbles: true,
-          })
-        ),
-      1
-    );
+    this.async(() => fireEvent(this, 'render-content'), 1);
   }
 
   cancel() {
@@ -363,15 +340,7 @@
     const message =
       `The value of the '${pref}' user preference is ` +
       'invalid. Fix in diff preferences';
-    this.dispatchEvent(
-      new CustomEvent('show-alert', {
-        detail: {
-          message,
-        },
-        bubbles: true,
-        composed: true,
-      })
-    );
+    fireAlert(this, message);
     throw Error(`Invalid preference value: ${pref}`);
   }
 
@@ -408,16 +377,14 @@
         diff,
         localPrefs,
         this.diffElement,
-        this._layers,
-        this.useNewContextControls
+        this._layers
       );
     } else if (this.viewMode === DiffViewMode.UNIFIED) {
       builder = new GrDiffBuilderUnified(
         diff,
         localPrefs,
         this.diffElement,
-        this._layers,
-        this.useNewContextControls
+        this._layers
       );
     }
     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 b10b251..0ae0e84 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
@@ -18,7 +18,6 @@
 import '../../../test/common-test-setup-karma.js';
 import '../gr-diff/gr-diff-group.js';
 import './gr-diff-builder.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
 import './gr-diff-builder-element.js';
 import {stubBaseUrl} from '../../../test/test-utils.js';
@@ -29,6 +28,8 @@
 import {GrDiffBuilder} from './gr-diff-builder.js';
 import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {DiffViewMode} from '../../../api/diff.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromTemplate(html`
     <gr-diff-builder>
@@ -46,11 +47,6 @@
     </gr-diff-builder>
 `);
 
-const DiffViewMode = {
-  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-  UNIFIED: 'UNIFIED_DIFF',
-};
-
 suite('gr-diff-builder tests', () => {
   let prefs;
   let element;
@@ -60,10 +56,8 @@
 
   setup(() => {
     element = basicFixture.instantiate();
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(false); },
-      getProjectConfig() { return Promise.resolve({}); },
-    });
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    stubRestApi('getProjectConfig').returns(Promise.resolve({}));
     stubBaseUrl('/r');
     prefs = {
       line_length: 10,
@@ -103,106 +97,57 @@
       return section;
     }
 
-    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');
-      });
+    setup(() => {
+      builder = new GrDiffBuilder({content: []}, prefs, null, []);
     });
 
-    suite('new style', () => {
-      setup(() => {
-        builder = new GrDiffBuilder(
-            {content: []}, prefs, null, [], true /* useNewContextControls */);
-      });
+    test('no +10 buttons for 10 or less lines', () => {
+      const section = createContextSectionForGroups({count: 10});
+      const buttons = section.querySelectorAll('gr-button.showContext');
 
-      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, '+10 common lines');
+    });
 
-        assert.equal(buttons.length, 1);
-        assert.equal(buttons[0].textContent, '+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');
 
-      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, '+20 common lines');
+      assert.equal(buttons[1].textContent, '+10');
 
-        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()], 'belowButton');
+      assert.include([...buttons[1].classList.values()], 'belowButton');
+    });
 
-        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');
 
-      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.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');
+    });
 
-        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');
 
-      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.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');
-      });
+      assert.include([...buttons[0].classList.values()], 'aboveButton');
+      assert.include([...buttons[1].classList.values()], 'aboveButton');
     });
   });
 
@@ -921,7 +866,7 @@
     });
 
     test('getSectionsByLineRange one line', () => {
-      const section = outputEl.querySelector('stub:nth-of-type(2)');
+      const section = outputEl.querySelector('stub:nth-of-type(3)');
       const sections = element._builder.getSectionsByLineRange(1, 1, 'left');
       assert.equal(sections.length, 1);
       assert.strictEqual(sections[0], section);
@@ -929,8 +874,8 @@
 
     test('getSectionsByLineRange over diff', () => {
       const section = [
-        outputEl.querySelector('stub:nth-of-type(2)'),
         outputEl.querySelector('stub:nth-of-type(3)'),
+        outputEl.querySelector('stub:nth-of-type(4)'),
       ];
       const sections = element._builder.getSectionsByLineRange(1, 2, 'left');
       assert.equal(sections.length, 2);
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 657dfa2..2025732 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,20 +17,19 @@
 
 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 {DiffViewMode, Side} from '../../../constants/constants';
+import {DiffLayer} from '../../../types/types';
 
 export class GrDiffBuilderSideBySide extends GrDiffBuilder {
   constructor(
     diff: DiffInfo,
     prefs: DiffPreferencesInfo,
     outputEl: HTMLElement,
-    // TODO(TS): Replace any by a layer interface.
-    readonly layers: any[] = [],
-    useNewContextControls = false
+    readonly layers: DiffLayer[] = []
   ) {
-    super(diff, prefs, outputEl, layers, useNewContextControls);
+    super(diff, prefs, outputEl, layers);
   }
 
   _getMoveControlsConfig() {
@@ -50,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));
     }
@@ -105,6 +104,7 @@
     row.classList.add('diff-row', 'side-by-side');
     row.setAttribute('left-type', leftLine.type);
     row.setAttribute('right-type', rightLine.type);
+    // TabIndex makes screen reader read a row when navigating with j/k
     row.tabIndex = -1;
 
     row.appendChild(this._createBlameCell(leftLine.beforeNumber));
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 2028b0c..1bf3d69 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,19 +17,18 @@
 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 {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {DiffViewMode, Side} from '../../../constants/constants';
+import {DiffLayer} from '../../../types/types';
 
 export class GrDiffBuilderUnified extends GrDiffBuilder {
   constructor(
     diff: DiffInfo,
     prefs: DiffPreferencesInfo,
     outputEl: HTMLElement,
-    // TODO(TS): Replace any by a layer interface.
-    readonly layers: any[] = [],
-    useNewContextControls = false
+    readonly layers: DiffLayer[] = []
   ) {
-    super(diff, prefs, outputEl, layers, useNewContextControls);
+    super(diff, prefs, outputEl, layers);
   }
 
   _getMoveControlsConfig() {
@@ -49,7 +48,7 @@
     if (group.dueToRebase) {
       sectionEl.classList.add('dueToRebase');
     }
-    if (group.dueToMove) {
+    if (group.moveDetails) {
       sectionEl.classList.add('dueToMove');
       sectionEl.appendChild(this._buildMoveControls(group));
     }
@@ -104,6 +103,7 @@
   _createRow(line: GrDiffLine) {
     const row = this._createElement('tr', line.type);
     row.classList.add('diff-row', 'unified');
+    // TabIndex makes screen reader read a row when navigating with j/k
     row.tabIndex = -1;
     row.appendChild(this._createBlameCell(line.beforeNumber));
     let lineNumberEl = this._createLineEl(
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..c08764e 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);
 
@@ -116,7 +116,7 @@
       assert.equal(rowEls.length, 3);
       assert.isTrue(moveControlsRow.classList.contains('movedOut'));
       assert.equal(cells.length, 3);
-      assert.isTrue(cells[2].classList.contains('moveDescription'));
+      assert.isTrue(cells[2].classList.contains('moveLabel'));
       assert.equal(cells[2].textContent, 'Moved out');
     });
 
@@ -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);
 
@@ -139,7 +139,7 @@
       assert.equal(rowEls.length, 3);
       assert.isTrue(moveControlsRow.classList.contains('movedIn'));
       assert.equal(cells.length, 3);
-      assert.isTrue(cells[2].classList.contains('moveDescription'));
+      assert.isTrue(cells[2].classList.contains('moveLabel'));
       assert.equal(cells[2].textContent, 'Moved in');
     });
   });
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 a3778ef..4943298 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
@@ -14,22 +14,26 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {
+  ContentLoadNeededEventDetail,
+  MovedLinkClickedEventDetail,
+} from '../../../api/diff';
 import {getBaseUrl} from '../../../utils/url-util';
 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 {BlameInfo} from '../../../types/common';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {DiffViewMode, Side} from '../../../constants/constants';
 import {DiffLayer} from '../../../types/types';
+import {pluralize} from '../../../utils/string-util';
 
 /**
  * In JS, unicode code points above 0xFFFF occupy two elements of a string.
- * For example '𐀏'.length is 2. An occurence of such a code point is called a
+ * For example '𐀏'.length is 2. An occurrence of such a code point is called a
  * surrogate pair.
  *
  * This regex segments a string along tabs ('\t') and surrogate pairs, since
@@ -65,10 +69,6 @@
   };
 }
 
-export interface ContentLoadNeededEventDetail {
-  lineRange: GrDiffGroupRange;
-}
-
 export abstract class GrDiffBuilder {
   private readonly _diff: DiffInfo;
 
@@ -80,7 +80,7 @@
 
   readonly groups: GrDiffGroup[];
 
-  private _blameInfo: BlameInfo[] | null;
+  private blameInfo: BlameInfo[] | null;
 
   private readonly _layerUpdateListener: (
     start: LineNumber,
@@ -92,8 +92,7 @@
     diff: DiffInfo,
     prefs: DiffPreferencesInfo,
     outputEl: HTMLElement,
-    readonly layers: DiffLayer[] = [],
-    protected readonly useNewContextControls: boolean = false
+    readonly layers: DiffLayer[] = []
   ) {
     this._diff = diff;
     this._numLinesLeft = this._diff.content
@@ -105,7 +104,7 @@
     this._prefs = prefs;
     this._outputEl = outputEl;
     this.groups = [];
-    this._blameInfo = null;
+    this.blameInfo = null;
 
     if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
       throw Error('Invalid tab size from preferences.');
@@ -179,9 +178,10 @@
       let groupStartLine = 0;
       let groupEndLine = 0;
       if (side) {
-        const range = rangeBySide(group.lineRange, side);
-        groupStartLine = range.start || 0;
-        groupEndLine = range.end || 0;
+        const range =
+          side === Side.LEFT ? group.lineRange.left : group.lineRange.right;
+        groupStartLine = range.start_line;
+        groupEndLine = range.end_line;
       }
 
       if (groupStartLine === 0) {
@@ -310,8 +310,9 @@
     contextGroups: GrDiffGroup[],
     viewMode: DiffViewMode
   ) {
-    const leftStart = contextGroups[0].lineRange.left.start!;
-    const leftEnd = contextGroups[contextGroups.length - 1].lineRange.left.end!;
+    const leftStart = contextGroups[0].lineRange.left.start_line;
+    const leftEnd =
+      contextGroups[contextGroups.length - 1].lineRange.left.end_line;
     const numLines = leftEnd - leftStart + 1;
 
     if (numLines === 0) console.error('context group without lines');
@@ -319,81 +320,16 @@
     const firstGroupIsSkipped = !!contextGroups[0].skip;
     const lastGroupIsSkipped = !!contextGroups[contextGroups.length - 1].skip;
 
-    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
-        )
-      );
+    if (showAbove) {
+      const paddingRow = this._createContextControlPaddingRow(viewMode);
+      paddingRow.classList.add('above');
+      section.appendChild(paddingRow);
     }
-  }
-
-  /**
-   * 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.appendChild(
+      this._createContextControlRow(
         section,
         contextGroups,
         showAbove,
@@ -401,58 +337,18 @@
         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,
-          section,
-          contextGroups,
-          numLines
-        )
-      );
-    }
-
-    td.appendChild(
-      this._createContextButton(
-        ContextButtonType.ALL,
-        section,
-        contextGroups,
-        numLines
-      )
-    );
-
     if (showBelow) {
-      td.appendChild(
-        this._createContextButton(
-          ContextButtonType.BELOW,
-          section,
-          contextGroups,
-          numLines
-        )
-      );
+      const paddingRow = this._createContextControlPaddingRow(viewMode);
+      paddingRow.classList.add('below');
+      section.appendChild(paddingRow);
     }
-
-    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.
+   * Creates context controls. Buttons extend from the gap created by this
+   * method up or down into the area of code that they affect.
    */
-  _createNewContextControlRow(
+  _createContextControlRow(
     section: HTMLElement,
     contextGroups: GrDiffGroup[],
     showAbove: boolean,
@@ -550,9 +446,7 @@
   ) {
     const context = PARTIAL_CONTEXT_AMOUNT;
     const button = this._createElement('gr-button', 'showContext');
-    if (this.useNewContextControls) {
-      button.classList.add('contextControlButton');
-    }
+    button.classList.add('contextControlButton');
     button.setAttribute('link', 'true');
     button.setAttribute('no-uppercase', 'true');
 
@@ -560,17 +454,11 @@
     let groups: GrDiffGroup[] = []; // The groups that replace this one if tapped.
     let requiresLoad = false;
     if (type === GrDiffBuilder.ContextButtonType.ALL) {
-      if (this.useNewContextControls) {
-        text = `+${numLines} common line`;
-      } 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';
-      }
+      text = `+${pluralize(numLines, 'common line')}`;
+      button.setAttribute(
+        'aria-label',
+        `Show ${pluralize(numLines, 'common line')}`
+      );
       requiresLoad = contextGroups.find(c => !!c.skip) !== undefined;
       if (requiresLoad) {
         // Expanding content would require load of more data
@@ -579,20 +467,20 @@
       groups.push(...contextGroups);
     } else if (type === GrDiffBuilder.ContextButtonType.ABOVE) {
       groups = hideInContextControl(contextGroups, context, numLines);
-      if (this.useNewContextControls) {
-        text = `+${context}`;
-        button.classList.add('aboveButton');
-      } else {
-        text = `+${context} above`;
-      }
+      text = `+${context}`;
+      button.classList.add('aboveButton');
+      button.setAttribute(
+        'aria-label',
+        `Show ${pluralize(context, 'line')} above`
+      );
     } else if (type === GrDiffBuilder.ContextButtonType.BELOW) {
       groups = hideInContextControl(contextGroups, 0, numLines - context);
-      if (this.useNewContextControls) {
-        text = `+${context}`;
-        button.classList.add('belowButton');
-      } else {
-        text = `+${context} below`;
-      }
+      text = `+${context}`;
+      button.classList.add('belowButton');
+      button.setAttribute(
+        'aria-label',
+        `Show ${pluralize(context, 'line')} below`
+      );
     }
     const textSpan = this._createElement('span', 'showContext');
     textSpan.textContent = text;
@@ -604,8 +492,14 @@
         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},
+          left: {
+            start_line: firstRange.left.start_line,
+            end_line: lastRange.left.end_line,
+          },
+          right: {
+            start_line: firstRange.right.start_line,
+            end_line: lastRange.right.end_line,
+          },
         };
         button.dispatchEvent(
           new CustomEvent<ContentLoadNeededEventDetail>('content-load-needed', {
@@ -647,7 +541,10 @@
       td.classList.add('lineNum');
       td.dataset['value'] = number.toString();
 
-      if (this._prefs.show_file_comment_button === false && number === 'FILE') {
+      if (
+        (this._prefs.show_file_comment_button === false && number === 'FILE') ||
+        number === 'LOST'
+      ) {
         return td;
       }
 
@@ -658,6 +555,9 @@
       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
@@ -692,7 +592,7 @@
     }
     td.classList.add(line.type);
 
-    if (line.beforeNumber !== 'FILE') {
+    if (line.beforeNumber !== 'FILE' && line.beforeNumber !== 'LOST') {
       const lineLimit = !this._prefs.line_wrapping
         ? this._prefs.line_length
         : Infinity;
@@ -717,9 +617,8 @@
       }
 
       td.appendChild(contentText);
-    } else {
-      td.classList.add('file');
-    }
+    } else if (line.beforeNumber === 'FILE') td.classList.add('file');
+    else if (line.beforeNumber === 'LOST') td.classList.add('lost');
 
     return td;
   }
@@ -866,7 +765,7 @@
    * re-render its blame cell content.
    */
   setBlame(blame: BlameInfo[] | null) {
-    this._blameInfo = blame;
+    this.blameInfo = blame;
     if (!blame) return;
 
     // TODO(wyatta): make this loop asynchronous.
@@ -891,6 +790,53 @@
     }
   }
 
+  _createMovedLineAnchor(line: number, side: Side) {
+    const anchor = this._createElementWithText('a', `${line}`);
+
+    // href is not actually used but important for Screen Readers
+    anchor.setAttribute('href', `#${line}`);
+    anchor.addEventListener('click', e => {
+      e.preventDefault();
+      anchor.dispatchEvent(
+        new CustomEvent<MovedLinkClickedEventDetail>('moved-link-clicked', {
+          detail: {
+            lineNum: line,
+            side,
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+    });
+    return anchor;
+  }
+
+  _createElementWithText(tagName: string, textContent: string) {
+    const element = this._createElement(tagName);
+    element.textContent = textContent;
+    return element;
+  }
+
+  _createMoveDescriptionDiv(movedIn: boolean, group: GrDiffGroup) {
+    const div = this._createElement('div');
+    if (group.moveDetails?.range) {
+      const {changed, range} = group.moveDetails;
+      const otherSide = movedIn ? Side.LEFT : Side.RIGHT;
+      const andChangedLabel = changed ? 'and changed ' : '';
+      const direction = movedIn ? 'from' : 'to';
+      const textLabel = `Moved ${andChangedLabel}${direction} lines `;
+      div.appendChild(this._createElementWithText('span', textLabel));
+      div.appendChild(this._createMovedLineAnchor(range.start, otherSide));
+      div.appendChild(this._createElementWithText('span', ' - '));
+      div.appendChild(this._createMovedLineAnchor(range.end, otherSide));
+    } else {
+      div.appendChild(
+        this._createElementWithText('span', movedIn ? 'Moved in' : 'Moved out')
+      );
+    }
+    return div;
+  }
+
   _buildMoveControls(group: GrDiffGroup) {
     const movedIn = group.adds.length > 0;
     const {
@@ -900,24 +846,27 @@
     } = this._getMoveControlsConfig();
 
     let controlsClass;
-    let descriptionText;
     let descriptionIndex;
+    const descriptionTextDiv = this._createMoveDescriptionDiv(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 controls = this._createElement('tr', `moveControls ${controlsClass}`);
     const cells = [...Array(numberOfCells).keys()].map(() =>
-      document.createElement('td')
+      this._createElement('td')
     );
-    controls.classList.add('moveControls', controlsClass);
-    cells[descriptionIndex].classList.add('moveDescription');
-    cells[descriptionIndex].textContent = descriptionText;
+    const moveDescriptionDiv = this._createElement('div', 'moveDescription');
+    const icon = this._createElement('iron-icon');
+    icon.setAttribute('icon', 'gr-icons:move-item');
+    moveDescriptionDiv.appendChild(icon);
+    moveDescriptionDiv.appendChild(descriptionTextDiv);
+    cells[descriptionIndex].appendChild(moveDescriptionDiv);
+    cells[descriptionIndex].classList.add('moveLabel');
     cells.forEach(c => {
       controls.appendChild(c);
     });
@@ -941,11 +890,11 @@
    * @return The commit information.
    */
   _getBlameCommitForBaseLine(lineNum: LineNumber) {
-    if (!this._blameInfo) {
+    if (!this.blameInfo) {
       return null;
     }
 
-    for (const blameCommit of this._blameInfo) {
+    for (const blameCommit of this.blameInfo) {
       for (const range of blameCommit.ranges) {
         if (range.start <= lineNum && range.end >= lineNum) {
           return blameCommit;
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 e7fb30e..cc3be07 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
@@ -20,6 +20,7 @@
   AbortStop,
   CursorMoveResult,
   GrCursorManager,
+  Stop,
   isTargetable,
 } from '../../shared/gr-cursor-manager/gr-cursor-manager';
 import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
@@ -28,6 +29,7 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-diff-cursor_html';
+import {DiffViewMode} from '../../../api/diff';
 import {ScrollMode, Side} from '../../../constants/constants';
 import {customElement, property, observe} from '@polymer/decorators';
 import {GrDiffLineType} from '../gr-diff/gr-diff-line';
@@ -35,11 +37,7 @@
 import {PolymerDomWrapper} from '../../../types/types';
 import {GrDiffGroupType} from '../gr-diff/gr-diff-group';
 import {GrDiff} from '../gr-diff/gr-diff';
-
-const DiffViewMode = {
-  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-  UNIFIED: 'UNIFIED_DIFF',
-};
+import {fireAlert, fireEvent} from '../../../utils/event-util';
 
 type GrDiffRowType = GrDiffLineType | GrDiffGroupType;
 
@@ -63,15 +61,7 @@
     return htmlTemplate;
   }
 
-  private _boundHandleWindowScroll: () => void;
-
-  private _boundHandleDiffRenderStart: () => void;
-
-  private _boundHandleDiffRenderContent: () => void;
-
-  private _boundHandleDiffLineSelected: (e: Event) => void;
-
-  private _preventAutoScrollOnManualScroll = false;
+  private preventAutoScrollOnManualScroll = false;
 
   private lastDisplayedNavigateToNextFileToast: number | null = null;
 
@@ -109,15 +99,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();
@@ -154,6 +135,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()) {
@@ -170,21 +161,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();
     }
   }
 
@@ -198,7 +189,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 =>
@@ -213,7 +207,7 @@
     if (
       navigateToNextFile &&
       result === CursorMoveResult.CLIPPED &&
-      this.$.cursorManager.isAtEnd()
+      this.isAtEnd()
     ) {
       if (
         this.lastDisplayedNavigateToNextFileToast &&
@@ -222,47 +216,43 @@
       ) {
         // reset for next file
         this.lastDisplayedNavigateToNextFileToast = null;
-        this.dispatchEvent(
-          new CustomEvent('navigate-to-next-unreviewed-file', {
-            composed: true,
-            bubbles: true,
-          })
-        );
+        fireEvent(this, 'navigate-to-next-unreviewed-file');
+      } else {
+        this.lastDisplayedNavigateToNextFileToast = Date.now();
+        fireAlert(this, 'Press n again to navigate to next unreviewed file');
       }
-      this.lastDisplayedNavigateToNextFileToast = Date.now();
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'Press n again to navigate to next unreviewed file',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
     }
 
     this._fixSide();
+    return result;
   }
 
-  moveToPreviousChunk() {
-    this.$.cursorManager.previous({
+  moveToPreviousChunk(): CursorMoveResult {
+    const result = this.$.cursorManager.previous({
       filter: (row: HTMLElement) => this._isFirstRowOfChunk(row),
     });
     this._fixSide();
+    return result;
   }
 
-  moveToNextCommentThread() {
-    this.$.cursorManager.next({
+  moveToNextCommentThread(): CursorMoveResult | undefined {
+    if (this.isAtEnd()) {
+      fireEvent(this, 'navigate-to-next-file-with-comments');
+      return;
+    }
+    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) {
@@ -302,12 +292,20 @@
 
   moveToFirstChunk() {
     this.$.cursorManager.moveToStart();
-    this.moveToNextChunk(true);
+    if (this.diffRow && !this._isFirstRowOfChunk(this.diffRow)) {
+      this.moveToNextChunk(true);
+    } else {
+      this._fixSide();
+    }
   }
 
   moveToLastChunk() {
     this.$.cursorManager.moveToEnd();
-    this.moveToPreviousChunk();
+    if (this.diffRow && !this._isFirstRowOfChunk(this.diffRow)) {
+      this.moveToPreviousChunk();
+    } else {
+      this._fixSide();
+    }
   }
 
   /**
@@ -339,13 +337,13 @@
     this._scrollMode = ScrollMode.KEEP_VISIBLE;
   }
 
-  _handleWindowScroll() {
-    if (this._preventAutoScrollOnManualScroll) {
+  private _boundHandleWindowScroll = () => {
+    if (this.preventAutoScrollOnManualScroll) {
       this._scrollMode = ScrollMode.NEVER;
       this._focusOnMove = false;
-      this._preventAutoScrollOnManualScroll = false;
+      this.preventAutoScrollOnManualScroll = false;
     }
-  }
+  };
 
   reInitAndUpdateStops() {
     this.reInit();
@@ -357,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;
-  }
+    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 =>
@@ -398,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) {
@@ -525,7 +526,7 @@
 
   _updateStops() {
     this.$.cursorManager.stops = this.diffs.reduce(
-      (stops: HTMLElement[], diff) => stops.concat(diff.getCursorStops()),
+      (stops: Stop[], diff) => stops.concat(diff.getCursorStops()),
       []
     );
   }
@@ -555,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
         );
@@ -570,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
         );
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..60b82da 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
@@ -18,14 +18,14 @@
 import '../../../test/common-test-setup-karma.js';
 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';
+import {createDefaultDiffPrefs} from '../../../constants/constants.js';
 
 const basicFixture = fixtureFromTemplate(html`
   <gr-diff></gr-diff>
   <gr-diff-cursor></gr-diff-cursor>
-  <gr-rest-api-interface></gr-rest-api-interface>
 `);
 
 const emptyFixture = fixtureFromElement('div');
@@ -39,7 +39,6 @@
     const fixtureElems = basicFixture.instantiate();
     diffElement = fixtureElems[0];
     cursorElement = fixtureElems[1];
-    const restAPI = fixtureElems[2];
 
     // Register the diff with the cursor.
     cursorElement.push('diffs', diffElement);
@@ -51,6 +50,7 @@
       right: [],
       meta: {patchRange: undefined},
     };
+    diffElement.path = 'some/path.ts';
     const setupDone = () => {
       cursorElement._updateStops();
       cursorElement.moveToFirstChunk();
@@ -60,10 +60,8 @@
     diffElement.addEventListener('render', setupDone);
 
     diff = getMockDiffResponse();
-    restAPI.getDiffPreferences().then(prefs => {
-      diffElement.prefs = prefs;
-      diffElement.diff = diff;
-    });
+    diffElement.prefs = createDefaultDiffPrefs();
+    diffElement.diff = diff;
   });
 
   test('diff cursor functionality (side-by-side)', () => {
@@ -85,29 +83,120 @@
     assert.equal(cursorElement.diffRow, firstDeltaRow);
   });
 
-  test('moveToLastChunk', () => {
+  test('moveToFirstChunk', async () => {
+    const diff = {
+      meta_a: {
+        name: 'lorem-ipsum.txt',
+        content_type: 'text/plain',
+        lines: 3,
+      },
+      meta_b: {
+        name: 'lorem-ipsum.txt',
+        content_type: 'text/plain',
+        lines: 3,
+      },
+      intraline_status: 'OK',
+      change_type: 'MODIFIED',
+      diff_header: [
+        'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
+        'index b2adcf4..554ae49 100644',
+        '--- a/lorem-ipsum.txt',
+        '+++ b/lorem-ipsum.txt',
+      ],
+      content: [
+        {b: ['new line 1']},
+        {ab: ['unchanged line']},
+        {a: ['old line 2']},
+        {ab: ['more unchanged lines']},
+      ],
+    };
+
+    diffElement.diff = diff;
+    // The file comment button, if present, is a cursor stop. Ensure
+    // moveToFirstChunk() works correctly even if the button is not shown.
+    diffElement.prefs.show_file_comment_button = false;
+    await flush();
+    cursorElement._updateStops();
+
     const chunks = Array.from(diffElement.root.querySelectorAll(
         '.section.delta'));
-    assert.isAbove(chunks.length, 1);
+    assert.equal(chunks.length, 2);
+
+    // Verify it works on fresh diff.
+    cursorElement.moveToFirstChunk();
     assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement), 0);
+    assert.equal(cursorElement.side, 'right');
 
+    // Verify it works from other cursor positions.
+    cursorElement.moveToNextChunk();
+    assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement), 1);
+    assert.equal(cursorElement.side, 'left');
+    cursorElement.moveToFirstChunk();
+    assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement), 0);
+    assert.equal(cursorElement.side, 'right');
+  });
+
+  test('moveToLastChunk', async () => {
+    const diff = {
+      meta_a: {
+        name: 'lorem-ipsum.txt',
+        content_type: 'text/plain',
+        lines: 3,
+      },
+      meta_b: {
+        name: 'lorem-ipsum.txt',
+        content_type: 'text/plain',
+        lines: 3,
+      },
+      intraline_status: 'OK',
+      change_type: 'MODIFIED',
+      diff_header: [
+        'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
+        'index b2adcf4..554ae49 100644',
+        '--- a/lorem-ipsum.txt',
+        '+++ b/lorem-ipsum.txt',
+      ],
+      content: [
+        {ab: ['unchanged line']},
+        {a: ['old line 2']},
+        {ab: ['more unchanged lines']},
+        {b: ['new line 3']},
+      ],
+    };
+
+    diffElement.diff = diff;
+    await flush();
+    cursorElement._updateStops();
+
+    const chunks = Array.from(diffElement.root.querySelectorAll(
+        '.section.delta'));
+    assert.equal(chunks.length, 2);
+
+    // Verify it works on fresh diff.
     cursorElement.moveToLastChunk();
+    assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement), 1);
+    assert.equal(cursorElement.side, 'right');
 
-    assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement),
-        chunks.length - 1);
+    // Verify it works from other cursor positions.
+    cursorElement.moveToPreviousChunk();
+    assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement), 0);
+    assert.equal(cursorElement.side, 'left');
+    cursorElement.moveToLastChunk();
+    assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement), 1);
+    assert.equal(cursorElement.side, 'right');
   });
 
   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 +206,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,7 +314,7 @@
     assert.equal(cursorElement.side, 'left');
   });
 
-  suite('moved chunks (dueToMove=true)', () => {
+  suite('moved chunks without line range)', () => {
     setup(done => {
       const renderHandler = function() {
         diffElement.removeEventListener('render', renderHandler);
@@ -245,7 +334,7 @@
             'Sagittis tincidunt torquent, tempor nunc amet.',
             'At rhoncus id.',
           ],
-          due_to_move: true,
+          move_details: {changed: false},
         },
         {
           ab: [
@@ -258,7 +347,7 @@
             'Sagittis tincidunt torquent, tempor nunc amet.',
             'At rhoncus id.',
           ],
-          due_to_move: true,
+          move_details: {changed: false},
         },
         {
           ab: [
@@ -268,27 +357,91 @@
       ]};
     });
 
-    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');
+    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');
+    });
 
-      // Move to the next chunk.
-      cursorElement.moveToNextChunk();
+    test('startLineAnchor of movedIn chunk fires events', done => {
+      const [movedIn] = diffElement.root
+          .querySelectorAll('.dueToMove .moveControls');
+      const [startLineAnchor] = movedIn.querySelectorAll('a');
 
-      // 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');
+      const onMovedLinkClicked = e => {
+        assert.deepEqual(e.detail, {lineNum: 4, side: 'left'});
+        done();
+      };
+      assert.equal(startLineAnchor.textContent, '4');
+      startLineAnchor
+          .addEventListener('moved-link-clicked', onMovedLinkClicked);
+      MockInteractions.click(startLineAnchor);
+    });
+
+    test('endLineAnchor of movedOut fires events', done => {
+      const [, movedOut] = diffElement.root
+          .querySelectorAll('.dueToMove .moveControls');
+      const [, endLineAnchor] = movedOut.querySelectorAll('a');
+
+      const onMovedLinkClicked = e => {
+        assert.deepEqual(e.detail, {lineNum: 4, side: 'right'});
+        done();
+      };
+      assert.equal(endLineAnchor.textContent, '4');
+      endLineAnchor.addEventListener('moved-link-clicked', onMovedLinkClicked);
+      MockInteractions.click(endLineAnchor);
     });
   });
 
@@ -369,10 +522,9 @@
     test('adds new draft for selected line on the left', done => {
       cursorElement.moveToLineNumber(2, 'left');
       diffElement.addEventListener('create-comment', e => {
-        const {lineNum, range, side, patchNum} = e.detail;
+        const {lineNum, range, side} = e.detail;
         assert.equal(lineNum, 2);
         assert.equal(range, undefined);
-        assert.equal(patchNum, 1);
         assert.equal(side, 'left');
         done();
       });
@@ -382,17 +534,16 @@
     test('adds draft for selected line on the right', done => {
       cursorElement.moveToLineNumber(4, 'right');
       diffElement.addEventListener('create-comment', e => {
-        const {lineNum, range, side, patchNum} = e.detail;
+        const {lineNum, range, side} = e.detail;
         assert.equal(lineNum, 4);
         assert.equal(range, undefined);
-        assert.equal(patchNum, 2);
         assert.equal(side, 'right');
         done();
       });
       cursorElement.createCommentInPlace();
     });
 
-    test('createCommentInPlace creates comment for range if selected', done => {
+    test('creates comment for range if selected', done => {
       const someRange = {
         start_line: 2,
         start_character: 3,
@@ -404,17 +555,16 @@
         range: someRange,
       };
       diffElement.addEventListener('create-comment', e => {
-        const {lineNum, range, side, patchNum} = e.detail;
+        const {lineNum, range, side} = e.detail;
         assert.equal(lineNum, 6);
         assert.equal(range, someRange);
-        assert.equal(patchNum, 2);
         assert.equal(side, 'right');
         done();
       });
       cursorElement.createCommentInPlace();
     });
 
-    test('createCommentInPlace ignores call if nothing is selected', () => {
+    test('ignores call if nothing is selected', () => {
       const createRangeCommentStub = sinon.stub(diffElement,
           'createRangeComment');
       const addDraftAtLineStub = sinon.stub(diffElement, 'addDraftAtLine');
@@ -456,7 +606,7 @@
 
   test('_findRowByNumberAndFile', () => {
     // Get the first ab row after the first chunk.
-    const row = diffElement.root.querySelectorAll('tr')[8];
+    const row = diffElement.root.querySelectorAll('tr')[9];
 
     // It should be line 8 on the right, but line 5 on the left.
     assert.equal(cursorElement._findRowByNumberAndFile(8, 'right'), row);
@@ -473,6 +623,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 +646,74 @@
       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>
+    `);
+
+    let diffElements;
+
+    setup(() => {
+      const fixtureElems = multiDiffFixture.instantiate();
+      diffElements = fixtureElems.slice(0, 3);
+      cursorElement = fixtureElems[3];
+
+      // Register the diff with the cursor.
+      cursorElement.push('diffs', ...diffElements);
+
+      for (const el of diffElements) {
+        el.prefs = createDefaultDiffPrefs();
+      }
+    });
+
+    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-highlight/gr-diff-highlight.ts b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts
index 3f43766..094f2d7 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts
@@ -30,6 +30,7 @@
 import {GrSelectionActionBox} from '../gr-selection-action-box/gr-selection-action-box';
 import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
 import {FILE} from '../gr-diff/gr-diff-line';
+import {getRange, getSide} from '../gr-diff/gr-diff-utils';
 
 interface SidedRange {
   side: Side;
@@ -53,6 +54,8 @@
   rootId: string;
 }
 
+const DEBOUNCER_SELECTION_CHANGE = 'selectionChange';
+
 @customElement('gr-diff-highlight')
 export class GrDiffHighlight extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
@@ -87,6 +90,11 @@
     );
   }
 
+  /** @override */
+  detached() {
+    this.cancelDebouncer(DEBOUNCER_SELECTION_CHANGE);
+  }
+
   get diffBuilder() {
     if (!this._cachedDiffBuilder) {
       this._cachedDiffBuilder = this.querySelector(
@@ -122,7 +130,7 @@
     // ms, then you will have about 50 _handleSelection calls when doing a
     // simple drag for select.
     this.debounce(
-      'selectionChange',
+      DEBOUNCER_SELECTION_CHANGE,
       () => this._handleSelection(selection, isMouseUp),
       10
     );
@@ -161,17 +169,35 @@
           `.range.${strToClassName(threadEl.rootId)}`
         );
         rangeNodes.forEach(rangeNode => {
-          rangeNode.classList.add('rangeHighlight');
-          rangeNode.classList.remove('range');
+          rangeNode.classList.add('rangeHoverHighlight');
         });
+        const chipNode = threadEl.parentElement?.querySelector(
+          `gr-ranged-comment-chip[threadElRootId="${threadEl.rootId}"]`
+        );
+        if (chipNode) {
+          chipNode.shadowRoot
+            ?.querySelectorAll('.rangeHighlight')
+            .forEach(highlightNode =>
+              highlightNode.classList.add('rangeHoverHighlight')
+            );
+        }
       } else {
         const rangeNodes = curNode.querySelectorAll(
-          `.rangeHighlight.${strToClassName(threadEl.rootId)}`
+          `.rangeHoverHighlight.${strToClassName(threadEl.rootId)}`
         );
         rangeNodes.forEach(rangeNode => {
-          rangeNode.classList.remove('rangeHighlight');
-          rangeNode.classList.add('range');
+          rangeNode.classList.remove('rangeHoverHighlight');
         });
+        const chipNode = threadEl.parentElement?.querySelector(
+          `gr-ranged-comment-chip[threadElRootId="${threadEl.rootId}"]`
+        );
+        if (chipNode) {
+          chipNode.shadowRoot
+            ?.querySelectorAll('.rangeHoverHighlight')
+            .forEach(highlightNode =>
+              highlightNode.classList.remove('rangeHoverHighlight')
+            );
+        }
       }
     }
   }
@@ -199,13 +225,9 @@
   }
 
   _indexForThreadEl(threadEl: HTMLElement) {
-    const side = threadEl.getAttribute('comment-side') as Side;
-    const rangeString = threadEl.getAttribute('range');
-    if (!rangeString) return undefined;
-    const range = JSON.parse(rangeString) as CommentRange;
-
-    if (!range) return undefined;
-
+    const side = getSide(threadEl);
+    const range = getRange(threadEl);
+    if (!side || !range) return undefined;
     return this._indexOfCommentRange(side, range);
   }
 
@@ -342,7 +364,7 @@
     const side = this.diffBuilder.getSideByLineEl(lineEl);
     if (!side) return null;
     const line = this.diffBuilder.getLineNumberByChild(lineEl);
-    if (!line || line === FILE) return null;
+    if (!line || line === FILE || line === 'LOST') return null;
     const contentTd = this.diffBuilder.getContentTdByLineEl(lineEl);
     if (!contentTd) return null;
     const contentText = contentTd.querySelector('.contentText');
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.js
index 39d5c2a..11b1f38 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.js
@@ -151,7 +151,7 @@
     test('comment-thread-mouseenter from line comments is ignored', () => {
       const threadEl = document.createElement('div');
       threadEl.className = 'comment-thread';
-      threadEl.setAttribute('comment-side', 'right');
+      threadEl.setAttribute('diff-side', 'right');
       threadEl.setAttribute('line-num', 3);
       element.appendChild(threadEl);
       element.commentRanges = [{side: 'right'}];
@@ -165,7 +165,7 @@
     test('comment-thread-mouseenter from ranged comment causes set', () => {
       const threadEl = document.createElement('div');
       threadEl.className = 'comment-thread';
-      threadEl.setAttribute('comment-side', 'right');
+      threadEl.setAttribute('diff-side', 'right');
       threadEl.setAttribute('line-num', 3);
       threadEl.setAttribute('range', JSON.stringify({
         start_line: 3,
@@ -193,7 +193,7 @@
     test('comment-thread-mouseleave from line comments is ignored', () => {
       const threadEl = document.createElement('div');
       threadEl.className = 'comment-thread';
-      threadEl.setAttribute('comment-side', 'right');
+      threadEl.setAttribute('diff-side', 'right');
       threadEl.setAttribute('line-num', 3);
       element.appendChild(threadEl);
       element.commentRanges = [{side: 'right'}];
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 c6f5d21..47b4a1f 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
@@ -14,9 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-comment-thread/gr-comment-thread';
-import '../../shared/gr-js-api-interface/gr-js-api-interface';
 import '../gr-diff/gr-diff';
 import '../gr-syntax-layer/gr-syntax-layer';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
@@ -24,54 +22,64 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-diff-host_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {rangesEqual} from '../gr-diff/gr-diff-utils';
+import {
+  getLine,
+  getRange,
+  getSide,
+  rangesEqual,
+} from '../gr-diff/gr-diff-utils';
 import {appContext} from '../../../services/app-context';
 import {
   getParentIndex,
+  isAParent,
   isMergeParent,
   isNumber,
 } from '../../../utils/patch-set-util';
-import {
-  Comment,
-  isDraft,
-  sortComments,
-  UIComment,
-} from '../../../utils/comment-util';
-import {TwoSidesComments} from '../gr-comment-api/gr-comment-api';
+import {CommentThread} from '../../../utils/comment-util';
 import {customElement, observe, property} from '@polymer/decorators';
 import {
   CommitRange,
   CoverageRange,
   DiffLayer,
   DiffLayerListener,
+  PatchSetFile,
 } from '../../../types/types';
 import {
   Base64ImageFile,
   BlameInfo,
   ChangeInfo,
   CommentRange,
-  DiffInfo,
-  DiffPreferencesInfo,
+  EditPatchSetNum,
   NumericChangeId,
+  ParentPatchSetNum,
   PatchRange,
   PatchSetNum,
   RepoName,
 } from '../../../types/common';
-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,
+  DiffInfo,
+  DiffPreferencesInfo,
   IgnoreWhitespaceType,
-  Side,
-} from '../../../constants/constants';
+} from '../../../types/diff';
+import {
+  CreateCommentEventDetail,
+  GrDiff,
+  LineOfInterest,
+} from '../gr-diff/gr-diff';
+import {GrSyntaxLayer} from '../gr-syntax-layer/gr-syntax-layer';
+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 {
+  firePageError,
+  fireAlert,
+  fireServerError,
+  fireEvent,
+} from '../../../utils/event-util';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {assertIsDefined} from '../../../utils/common-util';
 
 const MSG_EMPTY_BLAME = 'No blame information for this diff.';
 
@@ -109,24 +117,8 @@
   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;
-    jsAPI: JsApiService & Element;
     syntaxLayer: GrSyntaxLayer & Element;
     diff: GrDiff;
   };
@@ -211,8 +203,8 @@
   @property({type: Boolean})
   noRenderOnPrefsChange = false;
 
-  @property({type: Object, observer: '_commentsChanged'})
-  comments?: TwoSidesComments;
+  @property({type: Object, observer: '_threadsChanged'})
+  threads?: CommentThread[];
 
   @property({type: Boolean})
   lineWrapping = false;
@@ -274,7 +266,9 @@
 
   private readonly reporting = appContext.reportingService;
 
-  private readonly flags = appContext.flagsService;
+  private readonly restApiService = appContext.restApiService;
+
+  private readonly jsAPI = appContext.jsApiService;
 
   /** @override */
   created() {
@@ -289,11 +283,12 @@
       'create-comment',
       e => this._handleCreateComment(e)
     );
-    this.addEventListener('comment-discard', e =>
-      this._handleCommentDiscard(e)
+    this.addEventListener('comment-discard', () =>
+      this._handleCommentSaveOrDiscard()
     );
-    this.addEventListener('comment-update', e => this._handleCommentUpdate(e));
-    this.addEventListener('comment-save', e => this._handleCommentSave(e));
+    this.addEventListener('comment-save', () =>
+      this._handleCommentSaveOrDiscard()
+    );
     this.addEventListener('render-start', () => this._handleRenderStart());
     this.addEventListener('render-content', () => this._handleRenderContent());
     this.addEventListener('normalize-range', event =>
@@ -326,35 +321,53 @@
     this.clear();
   }
 
+  initLayers() {
+    return getPluginLoader()
+      .awaitPluginsLoaded()
+      .then(() => {
+        assertIsDefined(this.path, 'path');
+        assertIsDefined(this.changeNum, 'changeNum');
+        this._layers = this._getLayers(this.path, this.changeNum);
+        this._coverageRanges = [];
+        // We kick off fetching the data here, but we don't return the promise,
+        // so awaiting initLayers() will not wait for coverage data to be
+        // completely loaded.
+        this._getCoverageData();
+      });
+  }
+
   /**
    * @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();
-    if (!this.path) throw new Error('Missing required "path" property.');
-    if (!this.changeNum) throw new Error('Missing required "changeNum" prop.');
+    assertIsDefined(this.path, 'path');
+    assertIsDefined(this.changeNum, 'changeNum');
     this.diff = undefined;
     this._errorMessage = null;
     const whitespaceLevel = this._getIgnoreWhitespace();
 
-    this._layers = this._getLayers(this.path, this.changeNum);
-
     if (shouldReportMetric) {
       // We listen on render viewport only on DiffPage (on paramsChanged)
       this._listenToViewportRender();
     }
 
-    this._coverageRanges = [];
-    this._getCoverageData();
-
     try {
+      // We are carefully orchestrating operations that have to wait for another
+      // and operations that can be run in parallel. Plugins may provide layers,
+      // so we have to wait on plugins being loaded before we can initialize
+      // layers and proceed to rendering. OTOH we want to fetch diffs and diff
+      // assets in parallel.
+      const layerPromise = this.initLayers();
       const diff = await this._getDiff();
       this._loadedWhitespaceLevel = whitespaceLevel;
       this._reportDiff(diff);
 
       await this._loadDiffAssets(diff);
+      // Only now we are awaiting layers (and plugin loading), which was kicked
+      // off above.
+      await layerPromise;
 
       // Not waiting for coverage ranges intentionally as
       // plugin loading should not block the content rendering
@@ -389,7 +402,7 @@
 
   private _getLayers(path: string, changeNum: NumericChangeId): DiffLayer[] {
     // Get layers from plugins (if any).
-    return [this.$.syntaxLayer, ...this.$.jsAPI.getDiffLayers(path, changeNum)];
+    return [this.$.syntaxLayer, ...this.jsAPI.getDiffLayers(path, changeNum)];
   }
 
   private _onRenderOnce(): Promise<CustomEvent> {
@@ -403,15 +416,15 @@
   }
 
   clear() {
-    if (this.path) this.$.jsAPI.disposeDiffLayers(this.path);
+    if (this.path) this.jsAPI.disposeDiffLayers(this.path);
     this._layers = [];
   }
 
   _getCoverageData() {
-    if (!this.changeNum) throw new Error('Missing required "changeNum" prop.');
-    if (!this.change) throw new Error('Missing required "change" prop.');
-    if (!this.path) throw new Error('Missing required "path" prop.');
-    if (!this.patchRange) throw new Error('Missing required "patchRange".');
+    assertIsDefined(this.changeNum, 'changeNum');
+    assertIsDefined(this.change, 'change');
+    assertIsDefined(this.path, 'path');
+    assertIsDefined(this.patchRange, 'patchRange');
     const changeNum = this.changeNum;
     const change = this.change;
     const path = this.path;
@@ -422,7 +435,7 @@
 
     const basePatchNum = toNumberOnly(this.patchRange.basePatchNum);
     const patchNum = toNumberOnly(this.patchRange.patchNum);
-    this.$.jsAPI
+    this.jsAPI
       .getCoverageAnnotationApis()
       .then(coverageAnnotationApis => {
         coverageAnnotationApis.forEach(coverageAnnotationApi => {
@@ -430,7 +443,7 @@
           if (!provider) return;
           provider(changeNum, path, basePatchNum, patchNum, change)
             .then(coverageRanges => {
-              if (!this.patchRange) throw new Error('Missing "patchRange".');
+              assertIsDefined(this.patchRange, 'patchRange');
               if (
                 !coverageRanges ||
                 changeNum !== this.changeNum ||
@@ -483,13 +496,13 @@
         this.projectName,
         this.commitRange.baseCommit,
         this.path,
-        {weblinks: diff && diff.meta_a && diff.meta_a.web_links}
+        {weblinks: diff?.meta_a?.web_links}
       ),
       meta_b: GerritNav.getFileWebLinks(
         this.projectName,
         this.commitRange.commit,
         this.path,
-        {weblinks: diff && diff.meta_b && diff.meta_b.web_links}
+        {weblinks: diff?.meta_b?.web_links}
       ),
     };
   }
@@ -520,20 +533,14 @@
    * Load and display blame information for the base of the diff.
    */
   loadBlame(): Promise<BlameInfo[]> {
-    if (!this.changeNum) throw new Error('Missing required "changeNum" prop.');
-    if (!this.patchRange) throw new Error('Missing required "patchRange".');
-    if (!this.path) throw new Error('Missing required "path" property.');
-    return this.$.restAPI
+    assertIsDefined(this.changeNum, 'changeNum');
+    assertIsDefined(this.patchRange, 'patchRange');
+    assertIsDefined(this.path, 'path');
+    return this.restApiService
       .getBlame(this.changeNum, this.patchRange.patchNum, this.path, true)
       .then(blame => {
         if (!blame || !blame.length) {
-          this.dispatchEvent(
-            new CustomEvent('show-alert', {
-              detail: {message: MSG_EMPTY_BLAME},
-              composed: true,
-              bubbles: true,
-            })
-          );
+          fireAlert(this, MSG_EMPTY_BLAME);
           return Promise.reject(MSG_EMPTY_BLAME);
         }
 
@@ -558,12 +565,12 @@
     this.$.diff.clearDiffContent();
   }
 
-  expandAllContext() {
-    this.$.diff.expandAllContext();
+  toggleAllContext() {
+    this.$.diff.toggleAllContext();
   }
 
   _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
+    return this.restApiService.getLoggedIn();
   }
 
   _canReload() {
@@ -593,10 +600,10 @@
     // Wrap the diff request in a new promise so that the error handler
     // rejects the promise, allowing the error to be handled in the .catch.
     return new Promise((resolve, reject) => {
-      if (!this.changeNum) throw new Error('Missing required "changeNum".');
-      if (!this.patchRange) throw new Error('Missing required "patchRange".');
-      if (!this.path) throw new Error('Missing required "path" property.');
-      this.$.restAPI
+      assertIsDefined(this.changeNum, 'changeNum');
+      assertIsDefined(this.patchRange, 'patchRange');
+      assertIsDefined(this.path, 'path');
+      this.restApiService
         .getDiff(
           this.changeNum,
           this.patchRange.basePatchNum,
@@ -605,7 +612,7 @@
           this._getIgnoreWhitespace(),
           reject
         )
-        .then(resolve);
+        .then(diff => resolve(diff!)); // reject is called in case of error, so we can't get undefined here
     });
   }
 
@@ -613,13 +620,7 @@
     // Loading the diff may respond with 409 if the file is too large. In this
     // case, use a toast error..
     if (response.status === 409) {
-      this.dispatchEvent(
-        new CustomEvent('server-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireServerError(response);
       return;
     }
 
@@ -632,13 +633,7 @@
       return;
     }
 
-    this.dispatchEvent(
-      new CustomEvent('page-error', {
-        detail: {response},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    firePageError(response);
   }
 
   /**
@@ -675,7 +670,7 @@
 
     // Report the due_to_rebase percentage in the "diff" category when
     // applicable.
-    if (!this.patchRange) throw new Error('Missing required "patchRange".');
+    assertIsDefined(this.patchRange, 'patchRange');
     if (this.patchRange.basePatchNum === 'PARENT') {
       this.reporting.reportInteraction(EVENT_AGAINST_PARENT);
     } else if (percentRebaseDelta === 0) {
@@ -705,58 +700,26 @@
     return isImageDiff(diff);
   }
 
-  _commentsChanged(newComments: TwoSidesComments) {
-    const allComments = [];
-    for (const side of [Side.LEFT, Side.RIGHT]) {
-      // This is needed by the threading.
-      for (const comment of newComments[side]) {
-        comment.__commentSide = side;
-      }
-      allComments.push(...newComments[side]);
-    }
+  _threadsChanged(threads: CommentThread[]) {
     // Currently, the only way this is ever changed here is when the initial
-    // comments are loaded, so it's okay performance wise to clear the threads
+    // threads are loaded, so it's okay performance wise to clear the threads
     // and recreate them. If this changes in future, we might want to reuse
     // some DOM nodes here.
     this._clearThreads();
-    const threads = this._createThreads(allComments);
     for (const thread of threads) {
       const threadEl = this._createThreadElement(thread);
       this._attachThreadElement(threadEl);
     }
-  }
-
-  _createThreads(comments: UIComment[]): CommentThread[] {
-    const sortedComments = sortComments(comments);
-    const threads = [];
-    for (const comment of sortedComments) {
-      // If the comment is in reply to another comment, find that comment's
-      // thread and append to it.
-      if (comment.in_reply_to) {
-        const thread = threads.find(thread =>
-          thread.comments.some(c => c.id === comment.in_reply_to)
-        );
-        if (thread) {
-          thread.comments.push(comment);
-          continue;
-        }
-      }
-
-      // Otherwise, this comment starts its own thread.
-      if (!comment.__commentSide) throw new Error('Missing "__commentSide".');
-      const newThread: CommentThread = {
-        comments: [comment],
-        commentSide: comment.__commentSide,
-        patchNum: comment.patch_set,
-        lineNum: comment.line,
-        isOnParent: comment.side === 'PARENT',
-      };
-      if (comment.range) {
-        newThread.range = {...comment.range};
-      }
-      threads.push(newThread);
+    const portedThreadsCount = threads.filter(thread => thread.ported).length;
+    const portedThreadsWithoutRange = threads.filter(
+      thread => thread.ported && thread.rangeInfoLost
+    ).length;
+    if (portedThreadsCount > 0) {
+      this.reporting.reportInteraction('ported-threads-shown', {
+        ported: portedThreadsCount,
+        portedThreadsWithoutRange,
+      });
     }
-    return threads;
   }
 
   _computeIsBlameLoaded(blame: BlameInfo[] | null) {
@@ -764,29 +727,75 @@
   }
 
   _getImages(diff: DiffInfo) {
-    if (!this.changeNum) throw new Error('Missing required "changeNum" prop.');
-    if (!this.patchRange) throw new Error('Missing required "patchRange".');
-    return this.$.restAPI.getImagesForDiff(
+    assertIsDefined(this.changeNum, 'changeNum');
+    assertIsDefined(this.patchRange, 'patchRange');
+    return this.restApiService.getImagesForDiff(
       this.changeNum,
       diff,
       this.patchRange
     );
   }
 
-  _handleCreateComment(e: CustomEvent) {
-    const {lineNum, side, patchNum, isOnParent, range} = e.detail;
+  _handleCreateComment(e: CustomEvent<CreateCommentEventDetail>) {
+    if (!this.patchRange) throw Error('patch range not set');
+
+    const {lineNum, side, range, path} = e.detail;
+
+    // Usually, the comment is stored on the patchset shown on the side the
+    // user added the comment on, and the commentSide will be REVISION.
+    // However, if the comment is added on the left side of the diff and the
+    // version shown there is not a patchset that is part the change, but
+    // instead a base (a PARENT or a merge parent commit), the comment is
+    // stored on the patchset shown on the right, and commentSide=PARENT
+    // indicates that the comment should still be shown on the left side.
+    const patchNum =
+      side === Side.LEFT && !isAParent(this.patchRange.basePatchNum)
+        ? this.patchRange.basePatchNum
+        : this.patchRange.patchNum;
+    const commentSide =
+      side === Side.LEFT && isAParent(this.patchRange.basePatchNum)
+        ? CommentSide.PARENT
+        : CommentSide.REVISION;
+    if (!this.canCommentOnPatchSetNum(patchNum)) return;
     const threadEl = this._getOrCreateThread(
       patchNum,
       lineNum,
       side,
-      range,
-      isOnParent
+      commentSide,
+      path,
+      range
     );
     threadEl.addOrEditDraft(lineNum, range);
 
     this.reporting.recordDraftInteraction();
   }
 
+  private canCommentOnPatchSetNum(patchNum: PatchSetNum) {
+    if (!this._loggedIn) {
+      fireEvent(this, 'show-auth-required');
+      return false;
+    }
+    if (!this.patchRange) {
+      fireAlert(this, 'Cannot create comment. patchRange undefined.');
+      return false;
+    }
+
+    const isEdit = patchNum === EditPatchSetNum;
+    const isEditBase =
+      patchNum === ParentPatchSetNum &&
+      this.patchRange.patchNum === EditPatchSetNum;
+
+    if (isEdit) {
+      fireAlert(this, 'You cannot comment on an edit.');
+      return false;
+    }
+    if (isEditBase) {
+      fireAlert(this, 'You cannot comment on the base patchset of an edit.');
+      return false;
+    }
+    return true;
+  }
+
   /**
    * Gets or creates a comment thread at a given location.
    * May provide a range, to get/create a range comment.
@@ -794,19 +803,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);
     }
@@ -827,18 +838,21 @@
   _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 || 'LOST'}`
+    );
     threadEl.comments = thread.comments;
-    threadEl.commentSide = thread.commentSide;
-    threadEl.isOnParent = !!thread.isOnParent;
+    threadEl.diffSide = 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 {
@@ -847,8 +861,10 @@
     threadEl.changeNum = this.changeNum;
     threadEl.patchNum = thread.patchNum;
     threadEl.showPatchset = false;
+    threadEl.showPortedComment = !!thread.ported;
+    if (thread.rangeInfoLost) threadEl.lineNum = 'LOST';
     // GrCommentThread does not understand 'FILE', but requires undefined.
-    threadEl.lineNum = thread.lineNum !== 'FILE' ? thread.lineNum : undefined;
+    else threadEl.lineNum = thread.line !== 'FILE' ? thread.line : undefined;
     threadEl.projectName = this.projectName;
     threadEl.range = thread.range;
     const threadDiscardListener = (e: Event) => {
@@ -879,11 +895,7 @@
       throw new Error(`Unknown side: ${commentSide}`);
     }
     function matchesRange(threadEl: GrCommentThread) {
-      const rangeAtt = threadEl.getAttribute('range');
-      const threadRange = rangeAtt
-        ? (JSON.parse(rangeAtt) as CommentRange)
-        : undefined;
-      return rangesEqual(threadRange, range);
+      return rangesEqual(getRange(threadEl), range);
     }
 
     const filteredThreadEls = this._filterThreadElsForLocation(
@@ -901,35 +913,29 @@
   ) {
     function matchesLeftLine(threadEl: GrCommentThread) {
       return (
-        threadEl.getAttribute('comment-side') === Side.LEFT &&
-        threadEl.getAttribute('line-num') === String(lineInfo.beforeNumber)
+        getSide(threadEl) === Side.LEFT &&
+        getLine(threadEl) === lineInfo.beforeNumber
       );
     }
     function matchesRightLine(threadEl: GrCommentThread) {
       return (
-        threadEl.getAttribute('comment-side') === Side.RIGHT &&
-        threadEl.getAttribute('line-num') === String(lineInfo.afterNumber)
+        getSide(threadEl) === Side.RIGHT &&
+        getLine(threadEl) === lineInfo.afterNumber
       );
     }
     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')
-      );
+      return getSide(threadEl) === side && getLine(threadEl) === 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') {
+    if (lineInfo.afterNumber === FILE || lineInfo.beforeNumber === FILE) {
       matchers.push(matchesFileComment);
     }
     return threadEls.filter(threadEl =>
@@ -939,7 +945,7 @@
 
   _getIgnoreWhitespace(): IgnoreWhitespaceType {
     if (!this.prefs || !this.prefs.ignore_whitespace) {
-      return IgnoreWhitespaceType.IGNORE_NONE;
+      return 'IGNORE_NONE';
     }
     return this.prefs.ignore_whitespace;
   }
@@ -991,77 +997,8 @@
       : null;
   }
 
-  _handleCommentSave(e: CustomEvent) {
-    const comment = e.detail.comment;
-    const side = e.detail.comment.__commentSide;
-    const idx = this._findDraftIndex(comment, side);
-    this.set(['comments', side, idx], comment);
-    this._handleCommentSaveOrDiscard();
-  }
-
-  _handleCommentDiscard(e: CustomEvent) {
-    const comment = e.detail.comment;
-    this._removeComment(comment);
-    this._handleCommentSaveOrDiscard();
-  }
-
-  _handleCommentUpdate(e: CustomEvent) {
-    const comment = e.detail.comment;
-    const side = e.detail.comment.__commentSide;
-    let idx = this._findCommentIndex(comment, side);
-    if (idx === -1) {
-      idx = this._findDraftIndex(comment, side);
-    }
-    if (idx !== -1) {
-      // Update draft or comment.
-      this.set(['comments', side, idx], comment);
-    } else {
-      // Create new draft.
-      this.push(['comments', side], comment);
-    }
-  }
-
   _handleCommentSaveOrDiscard() {
-    this.dispatchEvent(
-      new CustomEvent('diff-comments-modified', {bubbles: true, composed: true})
-    );
-  }
-
-  _removeComment(comment: UIComment) {
-    const side = comment.__commentSide;
-    if (!side) throw new Error('Missing required "side" in comment.');
-    this._removeCommentFromSide(comment, side);
-  }
-
-  _removeCommentFromSide(comment: Comment, side: Side) {
-    let idx = this._findCommentIndex(comment, side);
-    if (idx === -1) {
-      idx = this._findDraftIndex(comment, side);
-    }
-    if (idx !== -1) {
-      this.splice('comments.' + side, idx, 1);
-    }
-  }
-
-  _findCommentIndex(comment: Comment, side: Side) {
-    if (!comment.id || !this.comments || !this.comments[side]) {
-      return -1;
-    }
-    return this.comments[side].findIndex(item => item.id === comment.id);
-  }
-
-  _findDraftIndex(comment: Comment, side: Side) {
-    if (
-      !isDraft(comment) ||
-      !comment.__draftID ||
-      !this.comments ||
-      !this.comments[side]
-    ) {
-      return -1;
-    }
-    return this.comments[side].findIndex(
-      item => isDraft(item) && item.__draftID === comment.__draftID
-    );
+    fireEvent(this, 'diff-comments-modified');
   }
 
   _isSyntaxHighlightingEnabled(
@@ -1071,18 +1008,26 @@
     >,
     diff?: DiffInfo
   ) {
-    if (
-      !preferenceChangeRecord ||
-      !preferenceChangeRecord.base ||
-      !preferenceChangeRecord.base.syntax_highlighting ||
-      !diff
-    ) {
+    if (!preferenceChangeRecord?.base?.syntax_highlighting || !diff) {
       return false;
     }
-    return (
-      !this._anyLineTooLong(diff) &&
-      this.$.diff.getDiffLength(diff) <= SYNTAX_MAX_DIFF_LENGTH
-    );
+    if (this._anyLineTooLong(diff)) {
+      fireAlert(
+        this,
+        `A line is longer than ${SYNTAX_MAX_LINE_LENGTH}.` +
+          ' Syntax Highlighting was turned off.'
+      );
+      return false;
+    }
+    if (this.$.diff.getDiffLength(diff) > SYNTAX_MAX_DIFF_LENGTH) {
+      fireAlert(
+        this,
+        `A diff is longer than ${SYNTAX_MAX_DIFF_LENGTH}.` +
+          ' Syntax Highlighting was turned off.'
+      );
+      return false;
+    }
+    return true;
   }
 
   /**
@@ -1200,10 +1145,6 @@
   _showNewlineWarningRight(diff?: DiffInfo) {
     return this._hasTrailingNewlines(diff, false) === false;
   }
-
-  _useNewContextControls() {
-    return this.flags.isEnabled(KnownExperimentId.NEW_CONTEXT_CONTROLS);
-  }
 }
 
 declare global {
@@ -1215,7 +1156,8 @@
 // TODO(TS): Be more specific than CustomEvent, which has detail:any.
 declare global {
   interface HTMLElementEventMap {
-    render: CustomEvent;
+    /* prettier-ignore */
+    'render': CustomEvent;
     'normalize-range': CustomEvent;
     'diff-context-expanded': CustomEvent;
     'create-comment': CustomEvent;
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 9921dd6..84a2e4a 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
@@ -21,7 +21,6 @@
     id="diff"
     change-num="[[changeNum]]"
     no-auto-render="[[noAutoRender]]"
-    patch-range="[[patchRange]]"
     path="[[path]]"
     prefs="[[prefs]]"
     display-line="[[displayLine]]"
@@ -41,7 +40,6 @@
     diff="[[diff]]"
     show-newline-warning-left="[[_showNewlineWarningLeft(diff)]]"
     show-newline-warning-right="[[_showNewlineWarningRight(diff)]]"
-    use-new-context-controls="[[_useNewContextControls()]]"
   >
   </gr-diff>
   <gr-syntax-layer
@@ -49,6 +47,4 @@
     enabled="[[_syntaxHighlightingEnabled]]"
     diff="[[diff]]"
   ></gr-syntax-layer>
-  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
index 37b3a50..01c78f0 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,250 +20,64 @@
 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 {createCommentThreads} from '../../../utils/comment-util.js';
 import {Side} from '../../../constants/constants.js';
 import {createChange} from '../../../test/test-data-generators.js';
+import {CoverageType} from '../../../types/types.js';
+import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
+import {createDefaultDiffPrefs} from '../../../constants/constants.js';
+import {EditPatchSetNum, ParentPatchSetNum} from '../../../types/common.js';
 
 const basicFixture = fixtureFromElement('gr-diff-host');
 
 suite('gr-diff-host tests', () => {
   let element;
 
-  let getLoggedIn;
+  let loggedIn;
 
-  setup(() => {
-    getLoggedIn = false;
-    stub('gr-rest-api-interface', {
-      async getLoggedIn() { return getLoggedIn; },
-    });
+  setup(async () => {
+    loggedIn = false;
+    stubRestApi('getLoggedIn').callsFake(() => Promise.resolve(loggedIn));
     element = basicFixture.instantiate();
     element.changeNum = 123;
     element.path = 'some/path';
     sinon.stub(element.reporting, 'time');
     sinon.stub(element.reporting, 'timeEnd');
+    await flush();
   });
 
   suite('plugin layers', () => {
     const pluginLayers = [{annotate: () => {}}, {annotate: () => {}}];
     setup(() => {
-      stub('gr-js-api-interface', {
-        getDiffLayers() { return pluginLayers; },
-      });
       element = basicFixture.instantiate();
+      sinon.stub(element.jsAPI, 'getDiffLayers').returns(pluginLayers);
       element.changeNum = 123;
       element.path = 'some/path';
     });
-    test('plugin layers requested', () => {
+    test('plugin layers requested', async () => {
       element.patchRange = {};
       element.change = createChange();
-      element.reload();
-      assert(element.$.jsAPI.getDiffLayers.called);
-    });
-  });
-
-  suite('handle comment-update', () => {
-    setup(() => {
-      sinon.stub(element, '_commentsChanged');
-      element.comments = {
-        meta: {
-          changeNum: '42',
-          patchRange: {
-            basePatchNum: 'PARENT',
-            patchNum: 3,
-          },
-          path: '/path/to/foo',
-          projectConfig: {foo: 'bar'},
-        },
-        left: [
-          {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-          {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
-          {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-          {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-        ],
-        right: [
-          {id: 'c1', __commentSide: 'right'},
-          {id: 'c2', __commentSide: 'right'},
-          {id: 'd1', __draft: true, __commentSide: 'right'},
-          {id: 'd2', __draft: true, __commentSide: 'right'},
-        ],
-      };
-    });
-
-    test('creating a draft', () => {
-      const comment = {__draft: true, __draftID: 'tempID', side: 'PARENT',
-        __commentSide: 'left'};
-      element.dispatchEvent(
-          new CustomEvent('comment-update', {
-            detail: {comment},
-            composed: true, bubbles: true,
-          }));
-      assert.include(element.comments.left, comment);
-    });
-
-    test('discarding a draft', () => {
-      const draftID = 'tempID';
-      const id = 'savedID';
-      const comment = {
-        __draft: true,
-        __draftID: draftID,
-        side: 'PARENT',
-        __commentSide: 'left',
-      };
-      const diffCommentsModifiedStub = sinon.stub();
-      element.addEventListener('diff-comments-modified',
-          diffCommentsModifiedStub);
-      element.comments.left.push(comment);
-      comment.id = id;
-      element.dispatchEvent(
-          new CustomEvent('comment-discard', {
-            detail: {comment},
-            composed: true, bubbles: true,
-          }));
-      const drafts = element.comments.left
-          .filter(item => item.__draftID === draftID);
-      assert.equal(drafts.length, 0);
-      assert.isTrue(diffCommentsModifiedStub.called);
-    });
-
-    test('saving a draft', () => {
-      const draftID = 'tempID';
-      const id = 'savedID';
-      const comment = {
-        __draft: true,
-        __draftID: draftID,
-        side: 'PARENT',
-        __commentSide: 'left',
-      };
-      const diffCommentsModifiedStub = sinon.stub();
-      element.addEventListener('diff-comments-modified',
-          diffCommentsModifiedStub);
-      element.comments.left.push(comment);
-      comment.id = id;
-      element.dispatchEvent(
-          new CustomEvent('comment-save', {
-            detail: {comment},
-            composed: true, bubbles: true,
-          }));
-      const drafts = element.comments.left
-          .filter(item => item.__draftID === draftID);
-      assert.equal(drafts.length, 1);
-      assert.equal(drafts[0].id, id);
-      assert.isTrue(diffCommentsModifiedStub.called);
-    });
-  });
-
-  test('remove comment', () => {
-    sinon.stub(element, '_commentsChanged');
-    element.comments = {
-      meta: {
-        changeNum: '42',
-        patchRange: {
-          basePatchNum: 'PARENT',
-          patchNum: 3,
-        },
-        path: '/path/to/foo',
-        projectConfig: {foo: 'bar'},
-      },
-      left: [
-        {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-        {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
-        {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-        {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-      ],
-      right: [
-        {id: 'c1', __commentSide: 'right'},
-        {id: 'c2', __commentSide: 'right'},
-        {id: 'd1', __draft: true, __commentSide: 'right'},
-        {id: 'd2', __draft: true, __commentSide: 'right'},
-      ],
-    };
-
-    // Using JSON.stringify because Safari 9.1 (11601.5.17.1) doesn’t seem
-    // to believe that one object deepEquals another even when they do :-/.
-    assert.equal(JSON.stringify(element.comments), JSON.stringify({
-      meta: {
-        changeNum: '42',
-        patchRange: {
-          basePatchNum: 'PARENT',
-          patchNum: 3,
-        },
-        path: '/path/to/foo',
-        projectConfig: {foo: 'bar'},
-      },
-      left: [
-        {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-        {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
-        {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-        {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-      ],
-      right: [
-        {id: 'c1', __commentSide: 'right'},
-        {id: 'c2', __commentSide: 'right'},
-        {id: 'd1', __draft: true, __commentSide: 'right'},
-        {id: 'd2', __draft: true, __commentSide: 'right'},
-      ],
-    }));
-
-    element._removeComment({id: 'bc2', side: 'PARENT',
-      __commentSide: 'left'});
-    assert.deepEqual(element.comments, {
-      meta: {
-        changeNum: '42',
-        patchRange: {
-          basePatchNum: 'PARENT',
-          patchNum: 3,
-        },
-        path: '/path/to/foo',
-        projectConfig: {foo: 'bar'},
-      },
-      left: [
-        {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-        {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-        {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-      ],
-      right: [
-        {id: 'c1', __commentSide: 'right'},
-        {id: 'c2', __commentSide: 'right'},
-        {id: 'd1', __draft: true, __commentSide: 'right'},
-        {id: 'd2', __draft: true, __commentSide: 'right'},
-      ],
-    });
-
-    element._removeComment({id: 'd2', __commentSide: 'right'});
-    assert.deepEqual(element.comments, {
-      meta: {
-        changeNum: '42',
-        patchRange: {
-          basePatchNum: 'PARENT',
-          patchNum: 3,
-        },
-        path: '/path/to/foo',
-        projectConfig: {foo: 'bar'},
-      },
-      left: [
-        {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-        {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-        {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-      ],
-      right: [
-        {id: 'c1', __commentSide: 'right'},
-        {id: 'c2', __commentSide: 'right'},
-        {id: 'd1', __draft: true, __commentSide: 'right'},
-      ],
+      stubRestApi('getDiff').returns(Promise.resolve({content: []}));
+      await element.reload();
+      assert(element.jsAPI.getDiffLayers.called);
     });
   });
 
   test('thread-discard handling', () => {
-    const threads = element._createThreads([
+    const threads = createCommentThreads([
       {
         id: 4711,
-        __commentSide: 'left',
+        diffSide: Side.LEFT,
         updated: '2015-12-20 15:01:20.396000000',
+        patch_set: 1,
+        path: 'some/path',
       },
       {
         id: 42,
-        __commentSide: 'left',
+        diffSide: Side.LEFT,
         updated: '2017-12-20 15:01:20.396000000',
+        patch_set: 1,
+        path: 'some/path',
       },
     ]);
     element._parentIndex = 1;
@@ -316,18 +130,16 @@
     test('ends total and syntax timer after syntax layer', async () => {
       sinon.stub(element.reporting, 'diffViewContentDisplayed');
       let notifySyntaxProcessed;
-      sinon.stub(element.$.syntaxLayer, 'process').returns(new Promise(
-          resolve => {
+      sinon.stub(element.$.syntaxLayer, 'process').returns(
+          new Promise(resolve => {
             notifySyntaxProcessed = resolve;
-          }));
-      sinon.stub(element.$.restAPI, 'getDiff').returns(
-          Promise.resolve({content: []}));
+          })
+      );
+      stubRestApi('getDiff').returns(Promise.resolve({content: []}));
       element.patchRange = {};
       element.change = createChange();
-      element.$.restAPI.getDiffPreferences().then(prefs => {
-        element.prefs = prefs;
-        return element.reload(true);
-      });
+      element.prefs = createDefaultDiffPrefs();
+      element.reload(true);
       // Multiple cascading microtasks are scheduled.
       await flush();
       notifySyntaxProcessed();
@@ -341,8 +153,7 @@
     });
 
     test('ends total timer w/ no syntax layer processing', async () => {
-      sinon.stub(element.$.restAPI, 'getDiff').returns(
-          Promise.resolve({content: []}));
+      stubRestApi('getDiff').returns(Promise.resolve({content: []}));
       element.patchRange = {};
       element.change = createChange();
       element.reload();
@@ -363,19 +174,15 @@
           resolve => {
             notifySyntaxProcessed = resolve;
           }));
-      sinon.stub(element.$.restAPI, 'getDiff').returns(
+      stubRestApi('getDiff').returns(
           Promise.resolve({content: []}));
       element.patchRange = {};
       element.change = createChange();
       let reloadComplete = false;
-      element.$.restAPI.getDiffPreferences()
-          .then(prefs => {
-            element.prefs = prefs;
-            return element.reload();
-          })
-          .then(() => {
-            reloadComplete = true;
-          });
+      element.prefs = createDefaultDiffPrefs();
+      element.reload().then(() => {
+        reloadComplete = true;
+      });
       // Multiple cascading microtasks are scheduled.
       await flush();
       assert.isFalse(reloadComplete);
@@ -405,435 +212,476 @@
     assert.isTrue(cancelStub.called);
   });
 
-  suite('not logged in', () => {
-    setup(() => {
-      getLoggedIn = false;
-      element = basicFixture.instantiate();
-      element.changeNum = 123;
-      element.change = createChange();
-      element.path = 'some/path';
-    });
-
-    test('reload() loads files weblinks', () => {
-      const weblinksStub = sinon.stub(GerritNav, '_generateWeblinks')
-          .returns({name: 'stubb', url: '#s'});
-      sinon.stub(element.$.restAPI, 'getDiff').returns(Promise.resolve({
-        content: [],
-      }));
-      element.projectName = 'test-project';
-      element.path = 'test-path';
-      element.commitRange = {baseCommit: 'test-base', commit: 'test-commit'};
-      element.patchRange = {};
-      return element.reload().then(() => {
-        assert.isTrue(weblinksStub.calledTwice);
-        assert.isTrue(weblinksStub.firstCall.calledWith({
-          commit: 'test-base',
-          file: 'test-path',
-          options: {
-            weblinks: undefined,
-          },
-          repo: 'test-project',
-          type: GerritNav.WeblinkType.FILE}));
-        assert.isTrue(weblinksStub.secondCall.calledWith({
-          commit: 'test-commit',
-          file: 'test-path',
-          options: {
-            weblinks: undefined,
-          },
-          repo: 'test-project',
-          type: GerritNav.WeblinkType.FILE}));
-        assert.deepEqual(element.filesWeblinks, {
-          meta_a: [{name: 'stubb', url: '#s'}],
-          meta_b: [{name: 'stubb', url: '#s'}],
-        });
+  test('reload() loads files weblinks', () => {
+    element.change = createChange();
+    const weblinksStub = sinon.stub(GerritNav, '_generateWeblinks')
+        .returns({name: 'stubb', url: '#s'});
+    stubRestApi('getDiff').returns(Promise.resolve({
+      content: [],
+    }));
+    element.projectName = 'test-project';
+    element.path = 'test-path';
+    element.commitRange = {baseCommit: 'test-base', commit: 'test-commit'};
+    element.patchRange = {};
+    return element.reload().then(() => {
+      assert.isTrue(weblinksStub.calledTwice);
+      assert.isTrue(weblinksStub.firstCall.calledWith({
+        commit: 'test-base',
+        file: 'test-path',
+        options: {
+          weblinks: undefined,
+        },
+        repo: 'test-project',
+        type: GerritNav.WeblinkType.FILE}));
+      assert.isTrue(weblinksStub.secondCall.calledWith({
+        commit: 'test-commit',
+        file: 'test-path',
+        options: {
+          weblinks: undefined,
+        },
+        repo: 'test-project',
+        type: GerritNav.WeblinkType.FILE}));
+      assert.deepEqual(element.filesWeblinks, {
+        meta_a: [{name: 'stubb', url: '#s'}],
+        meta_b: [{name: 'stubb', url: '#s'}],
       });
     });
+  });
 
-    test('prefetch getDiff', done => {
-      const diffRestApiStub = sinon.stub(element.$.restAPI, 'getDiff')
-          .returns(Promise.resolve({content: []}));
-      element.changeNum = 123;
-      element.patchRange = {basePatchNum: 1, patchNum: 2};
-      element.path = 'file.txt';
-      element.prefetchDiff();
-      element._getDiff().then(() =>{
-        assert.isTrue(diffRestApiStub.calledOnce);
+  test('prefetch getDiff', done => {
+    const diffRestApiStub = stubRestApi('getDiff')
+        .returns(Promise.resolve({content: []}));
+    element.changeNum = 123;
+    element.patchRange = {basePatchNum: 1, patchNum: 2};
+    element.path = 'file.txt';
+    element.prefetchDiff();
+    element._getDiff().then(() =>{
+      assert.isTrue(diffRestApiStub.calledOnce);
+      done();
+    });
+  });
+
+  test('_getDiff handles null diff responses', done => {
+    stubRestApi('getDiff').returns(Promise.resolve(null));
+    element.changeNum = 123;
+    element.patchRange = {basePatchNum: 1, patchNum: 2};
+    element.path = 'file.txt';
+    element._getDiff().then(done);
+  });
+
+  test('reload resolves on error', () => {
+    const onErrStub = sinon.stub(element, '_handleGetDiffError');
+    const error = new Response(null, {ok: false, status: 500});
+    stubRestApi('getDiff').callsFake(
+        (changeNum, basePatchNum, patchNum, path, whitespace, onErr) => {
+          onErr(error);
+        });
+    element.patchRange = {};
+    return element.reload().then(() => {
+      assert.isTrue(onErrStub.calledOnce);
+    });
+  });
+
+  suite('_handleGetDiffError', () => {
+    let serverErrorStub;
+    let pageErrorStub;
+
+    setup(() => {
+      serverErrorStub = sinon.stub();
+      addListenerForTest(document, 'server-error', serverErrorStub);
+      pageErrorStub = sinon.stub();
+      addListenerForTest(document, 'page-error', pageErrorStub);
+    });
+
+    test('page error on HTTP-409', () => {
+      element._handleGetDiffError({status: 409});
+      assert.isTrue(serverErrorStub.calledOnce);
+      assert.isFalse(pageErrorStub.called);
+      assert.isNotOk(element._errorMessage);
+    });
+
+    test('server error on non-HTTP-409', () => {
+      element._handleGetDiffError({
+        status: 500,
+        text: () => Promise.resolve(''),
+      });
+      assert.isFalse(serverErrorStub.called);
+      assert.isTrue(pageErrorStub.calledOnce);
+      assert.isNotOk(element._errorMessage);
+    });
+
+    test('error message if showLoadFailure', () => {
+      element.showLoadFailure = true;
+      element._handleGetDiffError({status: 500, statusText: 'Failure!'});
+      assert.isFalse(serverErrorStub.called);
+      assert.isFalse(pageErrorStub.called);
+      assert.equal(element._errorMessage,
+          'Encountered error when loading the diff: 500 Failure!');
+    });
+  });
+
+  suite('image diffs', () => {
+    let mockFile1;
+    let mockFile2;
+    setup(() => {
+      mockFile1 = {
+        body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+        'wsAAAAAAAAAAAAAAAAA/w==',
+        type: 'image/bmp',
+      };
+      mockFile2 = {
+        body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+        'wsAAAAAAAAAAAAA/////w==',
+        type: 'image/bmp',
+      };
+
+      element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
+      element.change = createChange();
+      element.comments = {
+        left: [],
+        right: [],
+        meta: {patchRange: element.patchRange},
+      };
+    });
+
+    test('renders image diffs with same file name', done => {
+      const mockDiff = {
+        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+        meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+          lines: 560},
+        intraline_status: 'OK',
+        change_type: 'MODIFIED',
+        diff_header: [
+          'diff --git a/carrot.jpg b/carrot.jpg',
+          'index 2adc47d..f9c2f2c 100644',
+          '--- a/carrot.jpg',
+          '+++ b/carrot.jpg',
+          'Binary files differ',
+        ],
+        content: [{skip: 66}],
+        binary: true,
+      };
+      stubRestApi('getDiff').returns(Promise.resolve(mockDiff));
+      stubRestApi('getImagesForDiff').returns(Promise.resolve({
+        baseImage: {
+          ...mockFile1,
+          _expectedType: 'image/jpeg',
+          _name: 'carrot.jpg',
+        },
+        revisionImage: {
+          ...mockFile2,
+          _expectedType: 'image/jpeg',
+          _name: 'carrot.jpg',
+        },
+      }));
+
+      const rendered = () => {
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assert.instanceOf(
+            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+
+        // Left image rendered with the parent commit's version of the file.
+        const leftImage =
+            element.$.diff.$.diffTable.querySelector('td.left img');
+        const leftLabel =
+            element.$.diff.$.diffTable.querySelector('td.left label');
+        const leftLabelContent = leftLabel.querySelector('.label');
+        const leftLabelName = leftLabel.querySelector('.name');
+
+        const rightImage =
+            element.$.diff.$.diffTable.querySelector('td.right img');
+        const rightLabel = element.$.diff.$.diffTable.querySelector(
+            'td.right label');
+        const rightLabelContent = rightLabel.querySelector('.label');
+        const rightLabelName = rightLabel.querySelector('.name');
+
+        assert.isNotOk(rightLabelName);
+        assert.isNotOk(leftLabelName);
+
+        let leftLoaded = false;
+        let rightLoaded = false;
+
+        leftImage.addEventListener('load', () => {
+          assert.isOk(leftImage);
+          assert.equal(leftImage.getAttribute('src'),
+              'data:image/bmp;base64, ' + mockFile1.body);
+          assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
+          leftLoaded = true;
+          if (rightLoaded) {
+            element.removeEventListener('render', rendered);
+            done();
+          }
+        });
+
+        rightImage.addEventListener('load', () => {
+          assert.isOk(rightImage);
+          assert.equal(rightImage.getAttribute('src'),
+              'data:image/bmp;base64, ' + mockFile2.body);
+          assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
+
+          rightLoaded = true;
+          if (leftLoaded) {
+            element.removeEventListener('render', rendered);
+            done();
+          }
+        });
+      };
+
+      element.addEventListener('render', rendered);
+      element.prefs = createDefaultDiffPrefs();
+      element.reload();
+    });
+
+    test('renders image diffs with a different file name', done => {
+      const mockDiff = {
+        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+        meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg',
+          lines: 560},
+        intraline_status: 'OK',
+        change_type: 'MODIFIED',
+        diff_header: [
+          'diff --git a/carrot.jpg b/carrot2.jpg',
+          'index 2adc47d..f9c2f2c 100644',
+          '--- a/carrot.jpg',
+          '+++ b/carrot2.jpg',
+          'Binary files differ',
+        ],
+        content: [{skip: 66}],
+        binary: true,
+      };
+      stubRestApi('getDiff').returns(Promise.resolve(mockDiff));
+      stubRestApi('getImagesForDiff').returns(Promise.resolve({
+        baseImage: {
+          ...mockFile1,
+          _expectedType: 'image/jpeg',
+          _name: 'carrot.jpg',
+        },
+        revisionImage: {
+          ...mockFile2,
+          _expectedType: 'image/jpeg',
+          _name: 'carrot2.jpg',
+        },
+      }));
+
+      const rendered = () => {
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assert.instanceOf(
+            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+
+        // Left image rendered with the parent commit's version of the file.
+        const leftImage =
+            element.$.diff.$.diffTable.querySelector('td.left img');
+        const leftLabel =
+            element.$.diff.$.diffTable.querySelector('td.left label');
+        const leftLabelContent = leftLabel.querySelector('.label');
+        const leftLabelName = leftLabel.querySelector('.name');
+
+        const rightImage =
+            element.$.diff.$.diffTable.querySelector('td.right img');
+        const rightLabel = element.$.diff.$.diffTable.querySelector(
+            'td.right label');
+        const rightLabelContent = rightLabel.querySelector('.label');
+        const rightLabelName = rightLabel.querySelector('.name');
+
+        assert.isOk(rightLabelName);
+        assert.isOk(leftLabelName);
+        assert.equal(leftLabelName.textContent, mockDiff.meta_a.name);
+        assert.equal(rightLabelName.textContent, mockDiff.meta_b.name);
+
+        let leftLoaded = false;
+        let rightLoaded = false;
+
+        leftImage.addEventListener('load', () => {
+          assert.isOk(leftImage);
+          assert.equal(leftImage.getAttribute('src'),
+              'data:image/bmp;base64, ' + mockFile1.body);
+          assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
+          leftLoaded = true;
+          if (rightLoaded) {
+            element.removeEventListener('render', rendered);
+            done();
+          }
+        });
+
+        rightImage.addEventListener('load', () => {
+          assert.isOk(rightImage);
+          assert.equal(rightImage.getAttribute('src'),
+              'data:image/bmp;base64, ' + mockFile2.body);
+          assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
+
+          rightLoaded = true;
+          if (leftLoaded) {
+            element.removeEventListener('render', rendered);
+            done();
+          }
+        });
+      };
+
+      element.addEventListener('render', rendered);
+      element.prefs = createDefaultDiffPrefs();
+      element.reload();
+    });
+
+    test('renders added image', done => {
+      const mockDiff = {
+        meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+          lines: 560},
+        intraline_status: 'OK',
+        change_type: 'ADDED',
+        diff_header: [
+          'diff --git a/carrot.jpg b/carrot.jpg',
+          'index 0000000..f9c2f2c 100644',
+          '--- /dev/null',
+          '+++ b/carrot.jpg',
+          'Binary files differ',
+        ],
+        content: [{skip: 66}],
+        binary: true,
+      };
+      stubRestApi('getDiff').returns(Promise.resolve(mockDiff));
+      stubRestApi('getImagesForDiff').returns(Promise.resolve({
+        baseImage: null,
+        revisionImage: {
+          ...mockFile2,
+          _expectedType: 'image/jpeg',
+          _name: 'carrot2.jpg',
+        },
+      }));
+
+      element.addEventListener('render', () => {
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assert.instanceOf(
+            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+
+        const leftImage =
+            element.$.diff.$.diffTable.querySelector('td.left img');
+        const rightImage =
+            element.$.diff.$.diffTable.querySelector('td.right img');
+
+        assert.isNotOk(leftImage);
+        assert.isOk(rightImage);
         done();
       });
+
+      element.prefs = createDefaultDiffPrefs();
+      element.reload();
     });
 
-    test('_getDiff handles null diff responses', done => {
-      stub('gr-rest-api-interface', {
-        getDiff() { return Promise.resolve(null); },
+    test('renders removed image', done => {
+      const mockDiff = {
+        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg',
+          lines: 560},
+        intraline_status: 'OK',
+        change_type: 'DELETED',
+        diff_header: [
+          'diff --git a/carrot.jpg b/carrot.jpg',
+          'index f9c2f2c..0000000 100644',
+          '--- a/carrot.jpg',
+          '+++ /dev/null',
+          'Binary files differ',
+        ],
+        content: [{skip: 66}],
+        binary: true,
+      };
+      stubRestApi('getDiff').returns(Promise.resolve(mockDiff));
+      stubRestApi('getImagesForDiff').returns(Promise.resolve({
+        baseImage: {
+          ...mockFile1,
+          _expectedType: 'image/jpeg',
+          _name: 'carrot.jpg',
+        },
+        revisionImage: null,
+      }));
+
+      element.addEventListener('render', () => {
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assert.instanceOf(
+            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+
+        const leftImage =
+            element.$.diff.$.diffTable.querySelector('td.left img');
+        const rightImage =
+            element.$.diff.$.diffTable.querySelector('td.right img');
+
+        assert.isOk(leftImage);
+        assert.isNotOk(rightImage);
+        done();
       });
-      element.changeNum = 123;
-      element.patchRange = {basePatchNum: 1, patchNum: 2};
-      element.path = 'file.txt';
-      element._getDiff().then(done);
+
+      element.prefs = createDefaultDiffPrefs();
+      element.reload();
     });
 
-    test('reload resolves on error', () => {
-      const onErrStub = sinon.stub(element, '_handleGetDiffError');
-      const error = new Response(null, {ok: false, status: 500});
-      sinon.stub(element.$.restAPI, 'getDiff').callsFake(
-          (changeNum, basePatchNum, patchNum, path, whitespace, onErr) => {
-            onErr(error);
-          });
-      element.patchRange = {};
-      return element.reload().then(() => {
-        assert.isTrue(onErrStub.calledOnce);
+    test('does not render disallowed image type', done => {
+      const mockDiff = {
+        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg-evil',
+          lines: 560},
+        intraline_status: 'OK',
+        change_type: 'DELETED',
+        diff_header: [
+          'diff --git a/carrot.jpg b/carrot.jpg',
+          'index f9c2f2c..0000000 100644',
+          '--- a/carrot.jpg',
+          '+++ /dev/null',
+          'Binary files differ',
+        ],
+        content: [{skip: 66}],
+        binary: true,
+      };
+      mockFile1.type = 'image/jpeg-evil';
+
+      stubRestApi('getDiff').returns(Promise.resolve(mockDiff));
+      stubRestApi('getImagesForDiff').returns(Promise.resolve({
+        baseImage: {
+          ...mockFile1,
+          _expectedType: 'image/jpeg',
+          _name: 'carrot.jpg',
+        },
+        revisionImage: null,
+      }));
+
+      element.addEventListener('render', () => {
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assert.instanceOf(
+            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+        const leftImage =
+            element.$.diff.$.diffTable.querySelector('td.left img');
+        assert.isNotOk(leftImage);
+        done();
       });
+
+      element.prefs = createDefaultDiffPrefs();
+      element.reload();
     });
+  });
 
-    suite('_handleGetDiffError', () => {
-      let serverErrorStub;
-      let pageErrorStub;
+  test('cannot create comments when not logged in', () => {
+    element.patchRange = {
+      basePatchNum: 'PARENT',
+      patchNum: 2,
+    };
+    const showAuthRequireSpy = sinon.spy();
+    element.addEventListener('show-auth-required', showAuthRequireSpy);
 
-      setup(() => {
-        serverErrorStub = sinon.stub();
-        element.addEventListener('server-error', serverErrorStub);
-        pageErrorStub = sinon.stub();
-        element.addEventListener('page-error', pageErrorStub);
-      });
+    element.dispatchEvent(new CustomEvent('create-comment', {
+      detail: {
+        lineNum: 3,
+        side: Side.LEFT,
+        path: '/p',
+      },
+    }));
 
-      test('page error on HTTP-409', () => {
-        element._handleGetDiffError({status: 409});
-        assert.isTrue(serverErrorStub.calledOnce);
-        assert.isFalse(pageErrorStub.called);
-        assert.isNotOk(element._errorMessage);
-      });
+    const threads = dom(element.$.diff)
+        .queryDistributedElements('gr-comment-thread');
 
-      test('server error on non-HTTP-409', () => {
-        element._handleGetDiffError({status: 500});
-        assert.isFalse(serverErrorStub.called);
-        assert.isTrue(pageErrorStub.calledOnce);
-        assert.isNotOk(element._errorMessage);
-      });
+    assert.equal(threads.length, 0);
 
-      test('error message if showLoadFailure', () => {
-        element.showLoadFailure = true;
-        element._handleGetDiffError({status: 500, statusText: 'Failure!'});
-        assert.isFalse(serverErrorStub.called);
-        assert.isFalse(pageErrorStub.called);
-        assert.equal(element._errorMessage,
-            'Encountered error when loading the diff: 500 Failure!');
-      });
-    });
-
-    suite('image diffs', () => {
-      let mockFile1;
-      let mockFile2;
-      setup(() => {
-        mockFile1 = {
-          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
-          'wsAAAAAAAAAAAAAAAAA/w==',
-          type: 'image/bmp',
-        };
-        mockFile2 = {
-          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
-          'wsAAAAAAAAAAAAA/////w==',
-          type: 'image/bmp',
-        };
-        sinon.stub(element.$.restAPI,
-            'getB64FileContents')
-            .callsFake(
-                (changeId, patchNum, path, opt_parentIndex) => Promise.resolve(
-                    opt_parentIndex === 1 ? mockFile1 : mockFile2)
-            );
-
-        element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
-        element.change = createChange();
-        element.comments = {
-          left: [],
-          right: [],
-          meta: {patchRange: element.patchRange},
-        };
-      });
-
-      test('renders image diffs with same file name', done => {
-        const mockDiff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'MODIFIED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot.jpg',
-            'index 2adc47d..f9c2f2c 100644',
-            '--- a/carrot.jpg',
-            '+++ b/carrot.jpg',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-        sinon.stub(element.$.restAPI, 'getDiff')
-            .returns(Promise.resolve(mockDiff));
-
-        const rendered = () => {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(
-              element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-
-          // Left image rendered with the parent commit's version of the file.
-          const leftImage =
-              element.$.diff.$.diffTable.querySelector('td.left img');
-          const leftLabel =
-              element.$.diff.$.diffTable.querySelector('td.left label');
-          const leftLabelContent = leftLabel.querySelector('.label');
-          const leftLabelName = leftLabel.querySelector('.name');
-
-          const rightImage =
-              element.$.diff.$.diffTable.querySelector('td.right img');
-          const rightLabel = element.$.diff.$.diffTable.querySelector(
-              'td.right label');
-          const rightLabelContent = rightLabel.querySelector('.label');
-          const rightLabelName = rightLabel.querySelector('.name');
-
-          assert.isNotOk(rightLabelName);
-          assert.isNotOk(leftLabelName);
-
-          let leftLoaded = false;
-          let rightLoaded = false;
-
-          leftImage.addEventListener('load', () => {
-            assert.isOk(leftImage);
-            assert.equal(leftImage.getAttribute('src'),
-                'data:image/bmp;base64, ' + mockFile1.body);
-            assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
-            leftLoaded = true;
-            if (rightLoaded) {
-              element.removeEventListener('render', rendered);
-              done();
-            }
-          });
-
-          rightImage.addEventListener('load', () => {
-            assert.isOk(rightImage);
-            assert.equal(rightImage.getAttribute('src'),
-                'data:image/bmp;base64, ' + mockFile2.body);
-            assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
-
-            rightLoaded = true;
-            if (leftLoaded) {
-              element.removeEventListener('render', rendered);
-              done();
-            }
-          });
-        };
-
-        element.addEventListener('render', rendered);
-
-        element.$.restAPI.getDiffPreferences().then(prefs => {
-          element.prefs = prefs;
-          element.reload();
-        });
-      });
-
-      test('renders image diffs with a different file name', done => {
-        const mockDiff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-          meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'MODIFIED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot2.jpg',
-            'index 2adc47d..f9c2f2c 100644',
-            '--- a/carrot.jpg',
-            '+++ b/carrot2.jpg',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-        sinon.stub(element.$.restAPI, 'getDiff')
-            .returns(Promise.resolve(mockDiff));
-
-        const rendered = () => {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(
-              element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-
-          // Left image rendered with the parent commit's version of the file.
-          const leftImage =
-              element.$.diff.$.diffTable.querySelector('td.left img');
-          const leftLabel =
-              element.$.diff.$.diffTable.querySelector('td.left label');
-          const leftLabelContent = leftLabel.querySelector('.label');
-          const leftLabelName = leftLabel.querySelector('.name');
-
-          const rightImage =
-              element.$.diff.$.diffTable.querySelector('td.right img');
-          const rightLabel = element.$.diff.$.diffTable.querySelector(
-              'td.right label');
-          const rightLabelContent = rightLabel.querySelector('.label');
-          const rightLabelName = rightLabel.querySelector('.name');
-
-          assert.isOk(rightLabelName);
-          assert.isOk(leftLabelName);
-          assert.equal(leftLabelName.textContent, mockDiff.meta_a.name);
-          assert.equal(rightLabelName.textContent, mockDiff.meta_b.name);
-
-          let leftLoaded = false;
-          let rightLoaded = false;
-
-          leftImage.addEventListener('load', () => {
-            assert.isOk(leftImage);
-            assert.equal(leftImage.getAttribute('src'),
-                'data:image/bmp;base64, ' + mockFile1.body);
-            assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
-            leftLoaded = true;
-            if (rightLoaded) {
-              element.removeEventListener('render', rendered);
-              done();
-            }
-          });
-
-          rightImage.addEventListener('load', () => {
-            assert.isOk(rightImage);
-            assert.equal(rightImage.getAttribute('src'),
-                'data:image/bmp;base64, ' + mockFile2.body);
-            assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
-
-            rightLoaded = true;
-            if (leftLoaded) {
-              element.removeEventListener('render', rendered);
-              done();
-            }
-          });
-        };
-
-        element.addEventListener('render', rendered);
-
-        element.$.restAPI.getDiffPreferences().then(prefs => {
-          element.prefs = prefs;
-          element.reload();
-        });
-      });
-
-      test('renders added image', done => {
-        const mockDiff = {
-          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'ADDED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot.jpg',
-            'index 0000000..f9c2f2c 100644',
-            '--- /dev/null',
-            '+++ b/carrot.jpg',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-        sinon.stub(element.$.restAPI, 'getDiff')
-            .returns(Promise.resolve(mockDiff));
-
-        element.addEventListener('render', () => {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(
-              element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-
-          const leftImage =
-              element.$.diff.$.diffTable.querySelector('td.left img');
-          const rightImage =
-              element.$.diff.$.diffTable.querySelector('td.right img');
-
-          assert.isNotOk(leftImage);
-          assert.isOk(rightImage);
-          done();
-        });
-
-        element.$.restAPI.getDiffPreferences().then(prefs => {
-          element.prefs = prefs;
-          element.reload();
-        });
-      });
-
-      test('renders removed image', done => {
-        const mockDiff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'DELETED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot.jpg',
-            'index f9c2f2c..0000000 100644',
-            '--- a/carrot.jpg',
-            '+++ /dev/null',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-        sinon.stub(element.$.restAPI, 'getDiff')
-            .returns(Promise.resolve(mockDiff));
-
-        element.addEventListener('render', () => {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(
-              element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-
-          const leftImage =
-              element.$.diff.$.diffTable.querySelector('td.left img');
-          const rightImage =
-              element.$.diff.$.diffTable.querySelector('td.right img');
-
-          assert.isOk(leftImage);
-          assert.isNotOk(rightImage);
-          done();
-        });
-
-        element.$.restAPI.getDiffPreferences().then(prefs => {
-          element.prefs = prefs;
-          element.reload();
-        });
-      });
-
-      test('does not render disallowed image type', done => {
-        const mockDiff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg-evil',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'DELETED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot.jpg',
-            'index f9c2f2c..0000000 100644',
-            '--- a/carrot.jpg',
-            '+++ /dev/null',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-        mockFile1.type = 'image/jpeg-evil';
-
-        sinon.stub(element.$.restAPI, 'getDiff')
-            .returns(Promise.resolve(mockDiff));
-
-        element.addEventListener('render', () => {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(
-              element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-          const leftImage =
-              element.$.diff.$.diffTable.querySelector('td.left img');
-          assert.isNotOk(leftImage);
-          done();
-        });
-
-        element.$.restAPI.getDiffPreferences().then(prefs => {
-          element.prefs = prefs;
-          element.reload();
-        });
-      });
-    });
+    assert.isTrue(showAuthRequireSpy.called);
   });
 
   test('delegates cancel()', () => {
@@ -870,10 +718,11 @@
   });
 
   suite('blame', () => {
-    setup(() => {
+    setup(async () => {
       element = basicFixture.instantiate();
       element.changeNum = 123;
       element.path = 'some/path';
+      await flush();
     });
 
     test('clearBlame', () => {
@@ -889,7 +738,7 @@
       const mockBlame = [{id: 'commit id', ranges: [{start: 1, end: 2}]}];
       const showAlertStub = sinon.stub();
       element.addEventListener('show-alert', showAlertStub);
-      const getBlameStub = sinon.stub(element.$.restAPI, 'getBlame')
+      const getBlameStub = stubRestApi('getBlame')
           .returns(Promise.resolve(mockBlame));
       element.changeNum = 42;
       element.patchRange = {patchNum: 5, basePatchNum: 4};
@@ -907,7 +756,7 @@
       const mockBlame = [];
       const showAlertStub = sinon.stub();
       element.addEventListener('show-alert', showAlertStub);
-      sinon.stub(element.$.restAPI, 'getBlame')
+      stubRestApi('getBlame')
           .returns(Promise.resolve(mockBlame));
       element.changeNum = 42;
       element.patchRange = {patchNum: 5, basePatchNum: 4};
@@ -947,9 +796,9 @@
     assert.equal(stub.lastCall.args.length, 0);
   });
 
-  test('delegates expandAllContext()', () => {
-    const stub = sinon.stub(element.$.diff, 'expandAllContext');
-    element.expandAllContext();
+  test('delegates toggleAllContext()', () => {
+    const stub = sinon.stub(element.$.diff, 'toggleAllContext');
+    element.toggleAllContext();
     assert.isTrue(stub.calledOnce);
     assert.equal(stub.lastCall.args.length, 0);
   });
@@ -965,12 +814,6 @@
     assert.equal(element.$.diff.noAutoRender, value);
   });
 
-  test('passes in patchRange', () => {
-    const value = {patchNum: 'foo', basePatchNum: 'bar'};
-    element.patchRange = value;
-    assert.equal(element.$.diff.patchRange, value);
-  });
-
   test('passes in path', () => {
     const value = 'some/file/path';
     element.path = value;
@@ -1028,12 +871,13 @@
   suite('_reportDiff', () => {
     let reportStub;
 
-    setup(() => {
+    setup(async () => {
       element = basicFixture.instantiate();
       element.changeNum = 123;
       element.path = 'file.txt';
       element.patchRange = {basePatchNum: 1};
       reportStub = sinon.stub(element.reporting, 'reportInteraction');
+      await flush();
     });
 
     test('null and content-less', () => {
@@ -1123,255 +967,255 @@
     });
   });
 
-  test('comments sorting', () => {
-    const comments = [
-      {
-        id: 'new_draft',
-        message: 'i do not like either of you',
-        __commentSide: 'left',
-        __draft: true,
-        updated: '2015-12-20 15:01:20.396000000',
-      },
-      {
-        id: 'sallys_confession',
-        message: 'i like you, jack',
-        updated: '2015-12-23 15:00:20.396000000',
-        line: 1,
-        __commentSide: 'left',
-      }, {
-        id: 'jacks_reply',
-        message: 'i like you, too',
-        updated: '2015-12-24 15:01:20.396000000',
-        __commentSide: 'left',
-        line: 1,
-        in_reply_to: 'sallys_confession',
-      },
-    ];
-    const sortedComments = sortComments(comments);
-    assert.equal(sortedComments[0], comments[1]);
-    assert.equal(sortedComments[1], comments[2]);
-    assert.equal(sortedComments[2], comments[0]);
-  });
+  suite('create-comment', () => {
+    setup(async () => {
+      loggedIn = true;
+      element.attached();
+      await flush();
+    });
 
-  test('_createThreads', () => {
-    const comments = [
-      {
-        id: 'sallys_confession',
-        message: 'i like you, jack',
-        updated: '2015-12-23 15:00:20.396000000',
-        line: 1,
-        __commentSide: 'left',
-      }, {
-        id: 'jacks_reply',
-        message: 'i like you, too',
-        updated: '2015-12-24 15:01:20.396000000',
-        __commentSide: 'left',
-        line: 1,
-        in_reply_to: 'sallys_confession',
-      },
-      {
-        id: 'new_draft',
-        message: 'i do not like either of you',
-        __commentSide: 'left',
-        __draft: true,
-        updated: '2015-12-20 15:01:20.396000000',
-      },
-    ];
+    test('creates comments if they do not exist yet', () => {
+      const diffSide = Side.LEFT;
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 2,
+      };
 
-    const actualThreads = element._createThreads(comments);
+      element.dispatchEvent(new CustomEvent('create-comment', {
+        detail: {
+          lineNum: 3,
+          side: diffSide,
+          path: '/p',
+        },
+      }));
 
-    assert.equal(actualThreads.length, 2);
+      let threads = dom(element.$.diff)
+          .queryDistributedElements('gr-comment-thread');
 
-    assert.equal(actualThreads[0].commentSide, '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(threads.length, 1);
+      assert.equal(threads[0].diffSide, diffSide);
+      assert.isTrue(threads[0].isOnParent);
+      assert.equal(threads[0].range, undefined);
+      assert.equal(threads[0].patchNum, 2);
 
-    assert.equal(actualThreads[1].commentSide, '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);
-  });
-
-  test('_createThreads inherits patchNum and range', () => {
-    const comments = [{
-      id: 'betsys_confession',
-      message: 'i like you, jack',
-      updated: '2015-12-24 15:00:10.396000000',
-      range: {
+      // Try to fetch a thread with a different range.
+      const range = {
         start_line: 1,
         start_character: 1,
         end_line: 1,
-        end_character: 2,
-      },
-      patch_set: 5,
-      __commentSide: 'left',
-      line: 1,
-    }];
+        end_character: 3,
+      };
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 3,
+      };
 
-    const expectedThreads = [
-      {
-        commentSide: 'left',
-        comments: [{
-          id: 'betsys_confession',
-          message: 'i like you, jack',
-          updated: '2015-12-24 15:00:10.396000000',
-          range: {
-            start_line: 1,
-            start_character: 1,
-            end_line: 1,
-            end_character: 2,
-          },
-          patch_set: 5,
-          __commentSide: 'left',
-          line: 1,
-        }],
-        patchNum: 5,
-        range: {
-          start_line: 1,
-          start_character: 1,
-          end_line: 1,
-          end_character: 2,
+      element.dispatchEvent(new CustomEvent('create-comment', {
+        detail: {
+          lineNum: 1,
+          side: diffSide,
+          path: '/p',
+          range,
         },
-        lineNum: 1,
-        isOnParent: false,
-      },
-    ];
+      }));
 
-    assert.deepEqual(
-        element._createThreads(comments),
-        expectedThreads);
-  });
+      threads = dom(element.$.diff)
+          .queryDistributedElements('gr-comment-thread');
 
-  test('_createThreads does not thread unrelated comments at same location',
-      () => {
-        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',
-          },
-        ];
-        assert.equal(element._createThreads(comments).length, 2);
-      });
+      assert.equal(threads.length, 2);
+      assert.equal(threads[1].diffSide, diffSide);
+      assert.isTrue(threads[0].isOnParent);
+      assert.equal(threads[1].range, range);
+      assert.equal(threads[1].patchNum, 3);
+    });
 
-  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',
-          },
-        ];
+    test('should not be on parent if on the right', () => {
+      element.patchRange = {
+        basePatchNum: 2,
+        patchNum: 3,
+      };
 
-        assert.equal(element._createThreads(comments)[0].isOnParent, false);
+      element.dispatchEvent(new CustomEvent('create-comment', {
+        detail: {
+          side: Side.RIGHT,
+        },
+      }));
 
-        comments[0].side = 'REVISION';
-        assert.equal(element._createThreads(comments)[0].isOnParent, false);
+      const thread = dom(element.$.diff)
+          .queryDistributedElements('gr-comment-thread')[0];
 
-        comments[0].side = 'PARENT';
-        assert.equal(element._createThreads(comments)[0].isOnParent, true);
-      });
+      assert.isFalse(thread.isOnParent);
+    });
 
-  test('_getOrCreateThread', () => {
-    const commentSide = 'left';
+    test('should be on parent if right and base is PARENT', () => {
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 3,
+      };
 
-    assert.isOk(element._getOrCreateThread('2', 3,
-        commentSide, undefined, false));
+      element.dispatchEvent(new CustomEvent('create-comment', {
+        detail: {
+          side: Side.LEFT,
+        },
+      }));
 
-    let threads = dom(element.$.diff)
-        .queryDistributedElements('gr-comment-thread');
+      const thread = dom(element.$.diff)
+          .queryDistributedElements('gr-comment-thread')[0];
 
-    assert.equal(threads.length, 1);
-    assert.equal(threads[0].commentSide, commentSide);
-    assert.equal(threads[0].range, undefined);
-    assert.equal(threads[0].isOnParent, false);
-    assert.equal(threads[0].patchNum, 2);
+      assert.isTrue(thread.isOnParent);
+    });
 
-    // Try to fetch a thread with a different range.
-    const range = {
-      start_line: 1,
-      start_character: 1,
-      end_line: 1,
-      end_character: 3,
-    };
+    test('should be on parent if right and base negative', () => {
+      element.patchRange = {
+        basePatchNum: -2, // merge parents have negative numbers
+        patchNum: 3,
+      };
 
-    assert.isOk(element._getOrCreateThread(
-        '3', 1, commentSide, range, true));
+      element.dispatchEvent(new CustomEvent('create-comment', {
+        detail: {
+          side: Side.LEFT,
+        },
+      }));
 
-    threads = dom(element.$.diff)
-        .queryDistributedElements('gr-comment-thread');
+      const thread = dom(element.$.diff)
+          .queryDistributedElements('gr-comment-thread')[0];
 
-    assert.equal(threads.length, 2);
-    assert.equal(threads[1].commentSide, commentSide);
-    assert.equal(threads[1].range, range);
-    assert.equal(threads[1].isOnParent, true);
-    assert.equal(threads[1].patchNum, 3);
-  });
+      assert.isTrue(thread.isOnParent);
+    });
 
-  test('thread should use old file path if first created' +
-   'on patch set (left) before renaming', () => {
-    const commentSide = 'left';
-    element.file = {basePath: 'file_renamed.txt', path: element.path};
+    test('should not be on parent otherwise', () => {
+      element.patchRange = {
+        basePatchNum: 2, // merge parents have negative numbers
+        patchNum: 3,
+      };
 
-    assert.isOk(element._getOrCreateThread('2', 3,
-        commentSide, undefined, /* isOnParent= */ false));
+      element.dispatchEvent(new CustomEvent('create-comment', {
+        detail: {
+          side: Side.LEFT,
+        },
+      }));
 
-    const threads = dom(element.$.diff)
-        .queryDistributedElements('gr-comment-thread');
+      const thread = dom(element.$.diff)
+          .queryDistributedElements('gr-comment-thread')[0];
 
-    assert.equal(threads.length, 1);
-    assert.equal(threads[0].commentSide, commentSide);
-    assert.equal(threads[0].path, element.file.basePath);
-  });
+      assert.isFalse(thread.isOnParent);
+    });
 
-  test('thread should use new file path if first created' +
-   'on patch set (right) after renaming', () => {
-    const commentSide = 'right';
-    element.file = {basePath: 'file_renamed.txt', path: element.path};
+    test('thread should use old file path if first created ' +
+    'on patch set (left) before renaming', () => {
+      const diffSide = Side.LEFT;
+      element.patchRange = {
+        basePatchNum: 2,
+        patchNum: 3,
+      };
+      element.file = {basePath: 'file_renamed.txt', path: element.path};
 
-    assert.isOk(element._getOrCreateThread('2', 3,
-        commentSide, undefined, /* isOnParent= */ false));
+      element.dispatchEvent(new CustomEvent('create-comment', {
+        detail: {
+          side: diffSide,
+          path: '/p',
+        },
+      }));
 
-    const threads = dom(element.$.diff)
-        .queryDistributedElements('gr-comment-thread');
+      const threads = dom(element.$.diff)
+          .queryDistributedElements('gr-comment-thread');
 
-    assert.equal(threads.length, 1);
-    assert.equal(threads[0].commentSide, commentSide);
-    assert.equal(threads[0].path, element.file.path);
-  });
+      assert.equal(threads.length, 1);
+      assert.equal(threads[0].diffSide, diffSide);
+      assert.equal(threads[0].path, element.file.basePath);
+    });
 
-  test('thread should use new file path if first created' +
-   'on patch set (left) but is base', () => {
-    const commentSide = 'left';
-    element.file = {basePath: 'file_renamed.txt', path: element.path};
+    test('thread should use new file path if first created' +
+    'on patch set (right) after renaming', () => {
+      const diffSide = Side.RIGHT;
+      element.patchRange = {
+        basePatchNum: 2,
+        patchNum: 3,
+      };
+      element.file = {basePath: 'file_renamed.txt', path: element.path};
 
-    assert.isOk(element._getOrCreateThread('2', 3,
-        commentSide, undefined, /* isOnParent= */ true));
+      element.dispatchEvent(new CustomEvent('create-comment', {
+        detail: {
+          side: diffSide,
+          path: '/p',
+        },
+      }));
 
-    const threads = dom(element.$.diff)
-        .queryDistributedElements('gr-comment-thread');
+      const threads = dom(element.$.diff)
+          .queryDistributedElements('gr-comment-thread');
 
-    assert.equal(threads.length, 1);
-    assert.equal(threads[0].commentSide, commentSide);
-    assert.equal(threads[0].path, element.file.path);
+      assert.equal(threads.length, 1);
+      assert.equal(threads[0].diffSide, 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 diffSide = Side.LEFT;
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 3,
+      };
+      element.file = {basePath: 'file_renamed.txt', path: element.path};
+
+      element.dispatchEvent(new CustomEvent('create-comment', {
+        detail: {
+          side: diffSide,
+          path: '/p',
+        },
+      }));
+
+      const threads = dom(element.$.diff)
+          .queryDistributedElements('gr-comment-thread');
+
+      assert.equal(threads.length, 1);
+      assert.equal(threads[0].diffSide, diffSide);
+      assert.equal(threads[0].path, element.file.path);
+    });
+
+    test('cannot create thread on an edit', () => {
+      const alertSpy = sinon.spy();
+      element.addEventListener('show-alert', alertSpy);
+
+      const diffSide = Side.LEFT;
+      element.patchRange = {
+        basePatchNum: EditPatchSetNum,
+        patchNum: 3,
+      };
+      element.dispatchEvent(new CustomEvent('create-comment', {
+        detail: {
+          side: diffSide,
+          path: '/p',
+        },
+      }));
+
+      const threads = dom(element.$.diff)
+          .queryDistributedElements('gr-comment-thread');
+      assert.equal(threads.length, 0);
+      assert.isTrue(alertSpy.called);
+    });
+
+    test('cannot create thread on an edit base', () => {
+      const alertSpy = sinon.spy();
+      element.addEventListener('show-alert', alertSpy);
+
+      const diffSide = Side.LEFT;
+      element.patchRange = {
+        basePatchNum: ParentPatchSetNum,
+        patchNum: EditPatchSetNum,
+      };
+      element.dispatchEvent(new CustomEvent('create-comment', {
+        detail: {
+          side: diffSide,
+          path: '/p',
+        },
+      }));
+
+      const threads = dom(element.$.diff)
+          .queryDistributedElements('gr-comment-thread');
+      assert.equal(threads.length, 0);
+      assert.isTrue(alertSpy.called);
+    });
   });
 
   test('_filterThreadElsForLocation with no threads', () => {
@@ -1390,23 +1234,21 @@
 
     const l3 = document.createElement('div');
     l3.setAttribute('line-num', 3);
-    l3.setAttribute('comment-side', 'left');
+    l3.setAttribute('diff-side', Side.LEFT);
 
     const l5 = document.createElement('div');
     l5.setAttribute('line-num', 5);
-    l5.setAttribute('comment-side', 'left');
+    l5.setAttribute('diff-side', Side.LEFT);
 
     const r3 = document.createElement('div');
     r3.setAttribute('line-num', 3);
-    r3.setAttribute('comment-side', 'right');
+    r3.setAttribute('diff-side', Side.RIGHT);
 
     const r5 = document.createElement('div');
     r5.setAttribute('line-num', 5);
-    r5.setAttribute('comment-side', 'right');
+    r5.setAttribute('diff-side', 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,
@@ -1417,18 +1259,14 @@
     const line = {beforeNumber: 'FILE', afterNumber: 'FILE'};
 
     const l = document.createElement('div');
-    l.setAttribute('comment-side', 'left');
+    l.setAttribute('diff-side', Side.LEFT);
     l.setAttribute('line-num', 'FILE');
 
     const r = document.createElement('div');
-    r.setAttribute('comment-side', 'right');
+    r.setAttribute('diff-side', Side.RIGHT);
     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,
@@ -1451,8 +1289,9 @@
       element.path = 'some/path';
     });
 
-    test('gr-diff-host provides syntax highlighting layer to gr-diff', () => {
-      element.reload();
+    test('gr-diff-host provides syntax highlighting layer', async () => {
+      stubRestApi('getDiff').returns(Promise.resolve({content: []}));
+      await element.reload();
       assert.equal(element.$.diff.layers[0], element.$.syntaxLayer);
     });
 
@@ -1478,10 +1317,8 @@
     test('starts syntax layer processing on render event', async () => {
       sinon.stub(element.$.syntaxLayer, 'process')
           .returns(Promise.resolve());
-      sinon.stub(element.$.restAPI, 'getDiff').returns(
-          Promise.resolve({content: []}));
-      element.reload();
-      await flush();
+      stubRestApi('getDiff').returns(Promise.resolve({content: []}));
+      await element.reload();
       element.dispatchEvent(
           new CustomEvent('render', {bubbles: true, composed: true}));
       assert.isTrue(element.$.syntaxLayer.process.called);
@@ -1506,8 +1343,9 @@
       element.prefs = prefs;
     });
 
-    test('gr-diff-host provides syntax highlighting layer', () => {
-      element.reload();
+    test('gr-diff-host provides syntax highlighting layer', async () => {
+      stubRestApi('getDiff').returns(Promise.resolve({content: []}));
+      await element.reload();
       assert.equal(element.$.diff.layers[0], element.$.syntaxLayer);
     });
 
@@ -1528,36 +1366,39 @@
 
   suite('coverage layer', () => {
     let notifyStub;
-    setup(() => {
+    let coverageProviderStub;
+    const exampleRanges = [
+      {
+        type: CoverageType.COVERED,
+        side: Side.RIGHT,
+        code_range: {
+          start_line: 1,
+          end_line: 2,
+        },
+      },
+      {
+        type: CoverageType.NOT_COVERED,
+        side: Side.RIGHT,
+        code_range: {
+          start_line: 3,
+          end_line: 4,
+        },
+      },
+    ];
+
+    setup(async () => {
       notifyStub = sinon.stub();
-      stub('gr-js-api-interface', {
-        getCoverageAnnotationApis() {
-          return Promise.resolve([{
+      coverageProviderStub = sinon.stub().returns(
+          Promise.resolve(exampleRanges));
+
+      element = basicFixture.instantiate();
+      sinon.stub(element.jsAPI, 'getCoverageAnnotationApis').returns(
+          Promise.resolve([{
             notify: notifyStub,
             getCoverageProvider() {
-              return () => Promise.resolve([
-                {
-                  type: 'COVERED',
-                  side: 'right',
-                  code_range: {
-                    start_line: 1,
-                    end_line: 2,
-                  },
-                },
-                {
-                  type: 'NOT_COVERED',
-                  side: 'right',
-                  code_range: {
-                    start_line: 3,
-                    end_line: 4,
-                  },
-                },
-              ]);
+              return coverageProviderStub;
             },
-          }]);
-        },
-      });
-      element = basicFixture.instantiate();
+          }]));
       element.changeNum = 123;
       element.change = createChange();
       element.path = 'some/path';
@@ -1574,23 +1415,42 @@
       };
       element.patchRange = {};
       element.prefs = prefs;
+      stubRestApi('getDiff').returns(Promise.resolve(element.diff));
+      await flush();
     });
 
-    test('getCoverageAnnotationApis should be called', done => {
-      element.reload();
-      flush(() => {
-        assert.isTrue(element.$.jsAPI.getCoverageAnnotationApis.calledOnce);
-        done();
-      });
+    test('getCoverageAnnotationApis should be called', async () => {
+      await element.reload();
+      assert.isTrue(element.jsAPI.getCoverageAnnotationApis.calledOnce);
     });
 
-    test('coverageRangeChanged should be called', done => {
-      element.reload();
-      flush(() => {
-        assert.equal(notifyStub.callCount, 2);
-        done();
-      });
+    test('coverageRangeChanged should be called', async () => {
+      await element.reload();
+      assert.equal(notifyStub.callCount, 2);
+      assert.isTrue(notifyStub.calledWithExactly(
+          'some/path', 1, 2, Side.RIGHT));
+      assert.isTrue(notifyStub.calledWithExactly(
+          'some/path', 3, 4, Side.RIGHT));
     });
+
+    test('provider is called with appropriate params', async () => {
+      element.patchRange.basePatchNum = 1;
+      element.patchRange.patchNum = 3;
+
+      await element.reload();
+      assert.isTrue(coverageProviderStub.calledWithExactly(
+          123, 'some/path', 1, 3, element.change));
+    });
+
+    test('provider is called with appropriate params - special patchset values',
+        async () => {
+          element.patchRange.basePatchNum = 'PARENT';
+          element.patchRange.patchNum = 'invalid';
+
+          await element.reload();
+          assert.isTrue(coverageProviderStub.calledWithExactly(
+              123, 'some/path', undefined, undefined, element.change));
+        });
   });
 
   suite('trailing newlines', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-overview-image.ts b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-overview-image.ts
new file mode 100644
index 0000000..42268e9
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-overview-image.ts
@@ -0,0 +1,304 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT 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 {
+  css,
+  customElement,
+  html,
+  internalProperty,
+  LitElement,
+  property,
+  PropertyValues,
+  query,
+} from 'lit-element';
+import {StyleInfo, styleMap} from 'lit-html/directives/style-map';
+
+import {Dimensions, fitToFrame, Point, Rect} from './util';
+
+/**
+ * Displays a scaled-down version of an image with a draggable frame for
+ * choosing a portion of the image to be magnified by other components.
+ *
+ * Slotted content can be arbitrary elements, but should be limited to images or
+ * stacks of image-like elements (e.g. for overlays) with limited interactivity,
+ * to prevent confusion, as the component only captures a limited set of events.
+ * Slotted content is scaled to fit the bounds of the component, with
+ * letterboxing if aspect ratios differ. For slotted content smaller than the
+ * component, it will cap the scale at 1x and also apply letterboxing.
+ */
+@customElement('gr-overview-image')
+export class GrOverviewImage extends LitElement {
+  @property({type: Object})
+  frameRect: Rect = {origin: {x: 0, y: 0}, dimensions: {width: 0, height: 0}};
+
+  @internalProperty() protected contentStyle: StyleInfo = {};
+
+  @internalProperty() protected contentTransformStyle: StyleInfo = {};
+
+  @internalProperty() protected frameStyle: StyleInfo = {};
+
+  @internalProperty() protected overlayStyle: StyleInfo = {};
+
+  @internalProperty() protected dragging = false;
+
+  @query('.content-box') protected contentBox!: HTMLDivElement;
+
+  @query('.content') protected content!: HTMLDivElement;
+
+  @query('.content-transform') protected contentTransform!: HTMLDivElement;
+
+  @query('.frame') protected frame!: HTMLDivElement;
+
+  private contentBounds: Dimensions = {width: 0, height: 0};
+
+  private imageBounds: Dimensions = {width: 0, height: 0};
+
+  private scale = 1;
+
+  // When grabbing the frame to drag it around, this stores the offset of the
+  // cursor from the center of the frame at the start of the drag.
+  private grabOffset: Point = {x: 0, y: 0};
+
+  private readonly resizeObserver = new ResizeObserver(
+    (entries: ResizeObserverEntry[]) => {
+      for (const entry of entries) {
+        if (entry.target === this.contentBox) {
+          this.contentBounds = {
+            width: entry.contentRect.width,
+            height: entry.contentRect.height,
+          };
+        }
+        if (entry.target === this.contentTransform) {
+          this.imageBounds = {
+            width: entry.contentRect.width,
+            height: entry.contentRect.height,
+          };
+        }
+        this.updateScale();
+      }
+    }
+  );
+
+  static styles = css`
+    :host {
+      --overview-image-background-color: #000;
+      --overview-image-frame-color: #f00;
+      display: flex;
+    }
+    * {
+      box-sizing: border-box;
+    }
+    ::slotted(*) {
+      display: block;
+    }
+    .content-box {
+      border: 1px solid var(--overview-image-background-color);
+      background-color: var(--overview-iamge-background-color);
+      width: 100%;
+      position: relative;
+    }
+    .content {
+      position: absolute;
+      cursor: pointer;
+    }
+    .content-transform {
+      position: absolute;
+      transform-origin: top left;
+      will-change: transform;
+    }
+    .frame {
+      border: 1px solid var(--overview-image-frame-color);
+      position: absolute;
+      will-change: transform;
+    }
+    .overlay {
+      position: absolute;
+      z-index: 10000;
+      cursor: grabbing;
+    }
+  `;
+
+  render() {
+    return html`
+      <div class="content-box">
+        <div
+          class="content"
+          style="${styleMap({
+            ...this.contentStyle,
+          })}"
+          @mousemove="${this.maybeDragFrame}"
+          @mousedown=${this.clickOverview}
+          @mouseup="${this.releaseFrame}"
+        >
+          <div
+            class="content-transform"
+            style="${styleMap(this.contentTransformStyle)}"
+          >
+            <slot></slot>
+          </div>
+          <div
+            class="frame"
+            style="${styleMap({
+              ...this.frameStyle,
+              cursor: this.dragging ? 'grabbing' : 'grab',
+            })}"
+            @mousedown="${this.grabFrame}"
+          ></div>
+        </div>
+        <div
+          class="overlay"
+          style="${styleMap({
+            ...this.overlayStyle,
+            display: this.dragging ? 'block' : 'none',
+          })}"
+          @mousemove="${this.overlayMouseMove}"
+          @mouseleave="${this.releaseFrame}"
+          @mouseup="${this.releaseFrame}"
+        ></div>
+      </div>
+    `;
+  }
+
+  firstUpdated() {
+    this.resizeObserver.observe(this.contentBox);
+    this.resizeObserver.observe(this.contentTransform);
+  }
+
+  updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('frameRect')) {
+      this.updateFrameStyle();
+    }
+  }
+
+  clickOverview(event: MouseEvent) {
+    event.preventDefault();
+
+    this.updateOverlaySize();
+
+    this.dragging = true;
+    const rect = this.content.getBoundingClientRect();
+    this.notifyNewCenter({
+      x: (event.clientX - rect.left) / this.scale,
+      y: (event.clientY - rect.top) / this.scale,
+    });
+  }
+
+  grabFrame(event: MouseEvent) {
+    event.preventDefault();
+    // Do not bubble up into clickOverview().
+    event.stopPropagation();
+
+    this.updateOverlaySize();
+
+    this.dragging = true;
+    const rect = this.frame.getBoundingClientRect();
+    const frameCenterX = rect.x + rect.width / 2;
+    const frameCenterY = rect.y + rect.height / 2;
+    this.grabOffset = {
+      x: event.clientX - frameCenterX,
+      y: event.clientY - frameCenterY,
+    };
+  }
+
+  maybeDragFrame(event: MouseEvent) {
+    event.preventDefault();
+    if (!this.dragging) return;
+    const rect = this.content.getBoundingClientRect();
+    const center = {
+      x: (event.clientX - rect.left - this.grabOffset.x) / this.scale,
+      y: (event.clientY - rect.top - this.grabOffset.y) / this.scale,
+    };
+    this.notifyNewCenter(center);
+  }
+
+  releaseFrame(event: MouseEvent) {
+    event.preventDefault();
+    this.dragging = false;
+    this.grabOffset = {x: 0, y: 0};
+  }
+
+  overlayMouseMove(event: MouseEvent) {
+    event.preventDefault();
+    this.maybeDragFrame(event);
+  }
+
+  private updateScale() {
+    const fitted = fitToFrame(this.imageBounds, this.contentBounds);
+    this.scale = fitted.scale;
+
+    this.contentStyle = {
+      ...this.contentStyle,
+      top: `${fitted.top}px`,
+      left: `${fitted.left}px`,
+      width: `${fitted.width}px`,
+      height: `${fitted.height}px`,
+    };
+
+    this.contentTransformStyle = {
+      transform: `scale(${this.scale})`,
+    };
+
+    this.updateFrameStyle();
+  }
+
+  private updateFrameStyle() {
+    const x = this.frameRect.origin.x * this.scale;
+    const y = this.frameRect.origin.y * this.scale;
+    const width = this.frameRect.dimensions.width * this.scale;
+    const height = this.frameRect.dimensions.height * this.scale;
+    this.frameStyle = {
+      ...this.frameStyle,
+      transform: `translate(${x}px, ${y}px)`,
+      width: `${width}px`,
+      height: `${height}px`,
+    };
+  }
+
+  private updateOverlaySize() {
+    const rect = this.contentBox.getBoundingClientRect();
+    // Create a whole-page overlay to capture mouse events, so that the drag
+    // interaction continues until the user releases the mouse button. Since
+    // innerWidth and innerHeight include scrollbars, we subtract 20 pixels each
+    // to prevent the overlay from extending offscreen under any existing
+    // scrollbar and causing the scrollbar for the other dimension to show up
+    // unnecessarily.
+    const width = window.innerWidth - 20;
+    const height = window.innerHeight - 20;
+    this.overlayStyle = {
+      ...this.overlayStyle,
+      top: `-${rect.top + 1}px`,
+      left: `-${rect.left + 1}px`,
+      width: `${width}px`,
+      height: `${height}px`,
+    };
+  }
+
+  private notifyNewCenter(center: Point) {
+    this.dispatchEvent(
+      new CustomEvent('center-updated', {
+        detail: {...center},
+        bubbles: true,
+        composed: true,
+      })
+    );
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-overview-image': GrOverviewImage;
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-zoomed-image.ts b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-zoomed-image.ts
new file mode 100644
index 0000000..a14a9cc
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-zoomed-image.ts
@@ -0,0 +1,95 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT 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 {
+  css,
+  customElement,
+  html,
+  internalProperty,
+  LitElement,
+  property,
+  PropertyValues,
+} from 'lit-element';
+import {StyleInfo, styleMap} from 'lit-html/directives/style-map';
+import {Rect} from './util';
+
+/**
+ * Displays its slotted content at a given scale, centered over a given point,
+ * while ensuring the content always fills the container. The content does not
+ * have to be a single image, it can be arbitrary HTML. To prevent user
+ * confusion, it should ideally be image-like, i.e. have limited or no
+ * interactivity, as the component does not prevent events or focus from
+ * reaching the slotted content.
+ */
+@customElement('gr-zoomed-image')
+export class GrZoomedImage extends LitElement {
+  @property({type: Number}) scale = 1;
+
+  @property({type: Object})
+  frameRect: Rect = {origin: {x: 0, y: 0}, dimensions: {width: 0, height: 0}};
+
+  @internalProperty() protected imageStyles: StyleInfo = {};
+
+  static styles = css`
+    :host {
+      display: block;
+    }
+    ::slotted(*) {
+      display: block;
+    }
+    #clip {
+      position: relative;
+      width: 100%;
+      height: 100%;
+      overflow: hidden;
+    }
+    #transform {
+      position: absolute;
+      transform-origin: top left;
+      will-change: transform;
+    }
+  `;
+
+  render() {
+    return html`
+      <div id="clip">
+        <div id="transform" style="${styleMap(this.imageStyles)}">
+          <slot></slot>
+        </div>
+      </div>
+    `;
+  }
+
+  updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('scale') || changedProperties.has('frameRect')) {
+      this.updateImageStyles();
+    }
+  }
+
+  private updateImageStyles() {
+    const {x, y} = this.frameRect.origin;
+    this.imageStyles = {
+      'image-rendering': this.scale >= 1 ? 'pixelated' : 'auto',
+      transform: `translate(${-x}px, ${-y}px) scale(${this.scale})`,
+    };
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-zoomed-image': GrZoomedImage;
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util.ts b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util.ts
new file mode 100644
index 0000000..b42eea9
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util.ts
@@ -0,0 +1,236 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export interface Point {
+  x: number;
+  y: number;
+}
+
+export interface Dimensions {
+  width: number;
+  height: number;
+}
+
+export interface Rect {
+  origin: Point;
+  dimensions: Dimensions;
+}
+
+export interface FittedContent {
+  top: number;
+  left: number;
+  width: number;
+  height: number;
+  scale: number;
+}
+
+function clamp(value: number, min: number, max: number) {
+  return Math.max(min, Math.min(value, max));
+}
+
+/**
+ * Fits content of the given dimensions into the given frame, maintaining the
+ * aspect ratio of the content and applying letterboxing / pillarboxing as
+ * needed.
+ */
+export function fitToFrame(
+  content: Dimensions,
+  frame: Dimensions
+): FittedContent {
+  const contentAspectRatio = content.width / content.height;
+  const frameAspectRatio = frame.width / frame.height;
+  // If the content is wider than the frame, it will be letterboxed, otherwise
+  // it will be pillarboxed. When letterboxed, content and frame width will
+  // match exactly, when pillarboxed, content and frame height will match
+  // exactly.
+  const isLetterboxed = contentAspectRatio > frameAspectRatio;
+  let width: number;
+  let height: number;
+  if (isLetterboxed) {
+    width = Math.min(frame.width, content.width);
+    height = content.height * (width / content.width);
+  } else {
+    height = Math.min(frame.height, content.height);
+    width = content.width * (height / content.height);
+  }
+  const top = (frame.height - height) / 2;
+  const left = (frame.width - width) / 2;
+  const scale = width / content.width;
+  return {top, left, width, height, scale};
+}
+
+function ensureInBounds(part: Rect, bounds: Dimensions): Rect {
+  const x =
+    part.dimensions.width <= bounds.width
+      ? clamp(part.origin.x, 0, bounds.width - part.dimensions.width)
+      : (bounds.width - part.dimensions.width) / 2;
+  const y =
+    part.dimensions.height <= bounds.height
+      ? clamp(part.origin.y, 0, bounds.height - part.dimensions.height)
+      : (bounds.height - part.dimensions.height) / 2;
+  return {origin: {x, y}, dimensions: part.dimensions};
+}
+
+/**
+ * Maintains a given frame inside given bounds, adjusting requested positions
+ * for the frame as needed. This supports the non-destructive application of a
+ * scaling factor, so that e.g. the magnification of an image can be changed
+ * easily while keeping the frame centered over the same spot. Changing bounds
+ * or frame size also keeps the frame position when possible.
+ */
+export class FrameConstrainer {
+  private center: Point = {x: 0, y: 0};
+
+  private frameSize: Dimensions = {width: 0, height: 0};
+
+  private bounds: Dimensions = {width: 0, height: 0};
+
+  private scale = 1;
+
+  private unscaledFrame: Rect = {
+    origin: {x: 0, y: 0},
+    dimensions: {width: 0, height: 0},
+  };
+
+  private scaledFrame: Rect = {
+    origin: {x: 0, y: 0},
+    dimensions: {width: 0, height: 0},
+  };
+
+  getCenter(): Point {
+    return {...this.center};
+  }
+
+  /**
+   * Returns the frame at its original size, positioned within the given bounds
+   * at the given scale; its origin will be in scaled bounds coordinates.
+   *
+   * Ex: for given bounds 100x50 and frame size 30x20, centered over (50, 25),
+   * all at 1x scale, when setting scale to 2, this will return a frame of size
+   * 30x20, centered over (100, 50), within bounds 200x100.
+   *
+   * Useful for positioning a viewport of fixed size over a magnified image.
+   */
+  getUnscaledFrame(): Rect {
+    return {
+      origin: {...this.unscaledFrame.origin},
+      dimensions: {...this.unscaledFrame.dimensions},
+    };
+  }
+
+  /**
+   * Returns the scaled down frame–a scale of 2 will result in frame dimensions
+   * being halved—position within the given bounds at 1x scale; its origin will
+   * be in unscaled bounds coordinates.
+   *
+   * Ex: for given bounds 100x50 and frame size 30x20, centered over (50, 25),
+   * all at 1x scale, when setting scale to 2, this will return a frame of size
+   * 15x10, centered over (50, 25), within bounds 100x50.
+   *
+   * Useful for highlighting the magnified portion of an image as determined by
+   * getUnscaledFrame() in an overview image of fixed size.
+   */
+  getScaledFrame(): Rect {
+    return {
+      origin: {...this.scaledFrame.origin},
+      dimensions: {...this.scaledFrame.dimensions},
+    };
+  }
+
+  /**
+   * Requests the frame to be centered over the given point, in unscaled bounds
+   * coordinates. This will keep the frame within the given bounds, also when
+   * requesting a center point fully outside the given bounds.
+   */
+  requestCenter(center: Point) {
+    this.center = {...center};
+
+    this.ensureFrameInBounds();
+  }
+
+  /**
+   * Sets the frame size, while keeping the frame within the given bounds, and
+   * maintaining the current center if possible.
+   */
+  setFrameSize(frameSize: Dimensions) {
+    if (frameSize.width <= 0 || frameSize.height <= 0) return;
+    this.frameSize = {...frameSize};
+
+    this.ensureFrameInBounds();
+  }
+
+  /**
+   * Sets the bounds, while keeping the frame within them, and maintaining the
+   * current center if possible.
+   */
+  setBounds(bounds: Dimensions) {
+    if (bounds.width <= 0 || bounds.height <= 0) return;
+    this.bounds = {...bounds};
+
+    this.ensureFrameInBounds();
+  }
+
+  /**
+   * Sets the applied scale, while keeping the frame within the given bounds,
+   * and maintaining the current center if possible (both relevant moving from
+   * a larger scale to a smaller scale).
+   */
+  setScale(scale: number) {
+    if (!scale || scale <= 0) return;
+    this.scale = scale;
+
+    this.ensureFrameInBounds();
+  }
+
+  private ensureFrameInBounds() {
+    const scaledCenter = {
+      x: this.center.x * this.scale,
+      y: this.center.y * this.scale,
+    };
+    const scaledBounds = {
+      width: this.bounds.width * this.scale,
+      height: this.bounds.height * this.scale,
+    };
+    const scaledFrameSize = {
+      width: this.frameSize.width / this.scale,
+      height: this.frameSize.height / this.scale,
+    };
+
+    const requestedUnscaledFrame = {
+      origin: {
+        x: scaledCenter.x - this.frameSize.width / 2,
+        y: scaledCenter.y - this.frameSize.height / 2,
+      },
+      dimensions: this.frameSize,
+    };
+    const requestedScaledFrame = {
+      origin: {
+        x: this.center.x - scaledFrameSize.width / 2,
+        y: this.center.y - scaledFrameSize.height / 2,
+      },
+      dimensions: scaledFrameSize,
+    };
+
+    this.unscaledFrame = ensureInBounds(requestedUnscaledFrame, scaledBounds);
+    this.scaledFrame = ensureInBounds(requestedScaledFrame, this.bounds);
+
+    this.center = {
+      x: this.scaledFrame.origin.x + this.scaledFrame.dimensions.width / 2,
+      y: this.scaledFrame.origin.y + this.scaledFrame.dimensions.height / 2,
+    };
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util_test.js b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util_test.js
new file mode 100644
index 0000000..80cfa36
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util_test.js
@@ -0,0 +1,171 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT 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 {FrameConstrainer} from './util.js';
+
+suite('FrameConstrainer tests', () => {
+  let constrainer;
+
+  setup(() => {
+    constrainer = new FrameConstrainer();
+    constrainer.setBounds({width: 100, height: 100});
+    constrainer.setFrameSize({width: 50, height: 50});
+    constrainer.requestCenter({x: 50, y: 50});
+  });
+
+  suite('changing center', () => {
+    test('moves frame to requested position', () => {
+      constrainer.requestCenter({x: 30, y: 30});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 5, y: 5}, dimensions: {width: 50, height: 50}});
+    });
+
+    test('keeps frame in bounds for top left corner', () => {
+      constrainer.requestCenter({x: 5, y: 5});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 0, y: 0}, dimensions: {width: 50, height: 50}});
+    });
+
+    test('keeps frame in bounds for bottom right corner', () => {
+      constrainer.requestCenter({x: 95, y: 95});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 50, y: 50}, dimensions: {width: 50, height: 50}});
+    });
+
+    test('handles out-of-bounds center left', () => {
+      constrainer.requestCenter({x: -5, y: 50});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 0, y: 25}, dimensions: {width: 50, height: 50}});
+    });
+
+    test('handles out-of-bounds center right', () => {
+      constrainer.requestCenter({x: 105, y: 50});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 50, y: 25}, dimensions: {width: 50, height: 50}});
+    });
+
+    test('handles out-of-bounds center top', () => {
+      constrainer.requestCenter({x: 50, y: -5});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 25, y: 0}, dimensions: {width: 50, height: 50}});
+    });
+
+    test('handles out-of-bounds center bottom', () => {
+      constrainer.requestCenter({x: 50, y: 105});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 25, y: 50}, dimensions: {width: 50, height: 50}});
+    });
+  });
+
+  suite('changing frame size', () => {
+    test('maintains center when decreased', () => {
+      constrainer.setFrameSize({width: 10, height: 10});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 45, y: 45}, dimensions: {width: 10, height: 10}});
+    });
+
+    test('maintains center when increased', () => {
+      constrainer.setFrameSize({width: 80, height: 80});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 10, y: 10}, dimensions: {width: 80, height: 80}});
+    });
+
+    test('updates center to remain in bounds when increased', () => {
+      constrainer.setFrameSize({width: 10, height: 10});
+      constrainer.requestCenter({x: 95, y: 95});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 90, y: 90}, dimensions: {width: 10, height: 10}});
+
+      constrainer.setFrameSize({width: 20, height: 20});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 80, y: 80}, dimensions: {width: 20, height: 20}});
+    });
+  });
+
+  suite('changing scale', () => {
+    suite('for unscaled frame', () => {
+      test('adjusts origin to maintain center when zooming in', () => {
+        constrainer.setScale(2);
+        assert.deepEqual(
+            constrainer.getUnscaledFrame(),
+            {origin: {x: 75, y: 75}, dimensions: {width: 50, height: 50}});
+      });
+
+      test('adjusts origin to maintain center when zooming out', () => {
+        constrainer.setFrameSize({width: 20, height: 20});
+        constrainer.setScale(0.5);
+        assert.deepEqual(
+            constrainer.getUnscaledFrame(),
+            {origin: {x: 15, y: 15}, dimensions: {width: 20, height: 20}});
+      });
+
+      test('keeps frame in bounds when zooming out', () => {
+        constrainer.setScale(5);
+        constrainer.requestCenter({x: 100, y: 100});
+        assert.deepEqual(
+            constrainer.getUnscaledFrame(),
+            {origin: {x: 450, y: 450}, dimensions: {width: 50, height: 50}});
+
+        constrainer.setScale(1);
+        assert.deepEqual(
+            constrainer.getUnscaledFrame(),
+            {origin: {x: 50, y: 50}, dimensions: {width: 50, height: 50}});
+      });
+    });
+
+    suite('for scaled frame', () => {
+      test('decreases frame size and maintains center when zooming in', () => {
+        constrainer.setScale(2);
+        assert.deepEqual(
+            constrainer.getScaledFrame(),
+            {origin: {x: 37.5, y: 37.5}, dimensions: {width: 25, height: 25}});
+      });
+
+      test('increases frame size and maintains center when zooming out', () => {
+        constrainer.setFrameSize({width: 20, height: 20});
+        constrainer.setScale(0.5);
+        assert.deepEqual(
+            constrainer.getScaledFrame(),
+            {origin: {x: 30, y: 30}, dimensions: {width: 40, height: 40}});
+      });
+
+      test('keeps frame in bounds when zooming out', () => {
+        constrainer.setScale(5);
+        constrainer.requestCenter({x: 100, y: 100});
+        assert.deepEqual(
+            constrainer.getScaledFrame(),
+            {origin: {x: 90, y: 90}, dimensions: {width: 10, height: 10}});
+
+        constrainer.setScale(1);
+        assert.deepEqual(
+            constrainer.getScaledFrame(),
+            {origin: {x: 50, y: 50}, dimensions: {width: 50, height: 50}});
+      });
+    });
+  });
+});
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
index e0333cc..60f2853 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
@@ -18,9 +18,7 @@
 import '@polymer/iron-a11y-announcer/iron-a11y-announcer';
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import {DiffViewMode} from '../../../constants/constants';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
@@ -28,12 +26,7 @@
 import {customElement, property} from '@polymer/decorators';
 import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
 import {FixIronA11yAnnouncer} from '../../../types/types';
-
-export interface GrDiffModeSelector {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
+import {appContext} from '../../../services/app-context';
 
 @customElement('gr-diff-mode-selector')
 export class GrDiffModeSelector extends GestureEventListeners(
@@ -53,6 +46,8 @@
   @property({type: Boolean})
   saveOnChange = false;
 
+  private readonly restApiService = appContext.restApiService;
+
   attached() {
     ((IronA11yAnnouncer as unknown) as FixIronA11yAnnouncer).requestAvailability();
   }
@@ -62,20 +57,20 @@
    */
   setMode(newMode: DiffViewMode) {
     if (this.saveOnChange && this.mode && this.mode !== newMode) {
-      this.$.restAPI.savePreferences({diff_view: newMode});
+      this.restApiService.savePreferences({diff_view: newMode});
     }
     this.mode = newMode;
-    let annoucement;
+    let announcement;
     if (this.isUnifiedSelected(newMode)) {
-      annoucement = 'Changed diff view to unified';
+      announcement = 'Changed diff view to unified';
     } else if (this.isSideBySideSelected(newMode)) {
-      annoucement = 'Changed diff view to side by side';
+      announcement = 'Changed diff view to side by side';
     }
-    if (annoucement) {
+    if (announcement) {
       this.fire(
         'iron-announce',
         {
-          text: annoucement,
+          text: announcement,
         },
         {bubbles: true}
       );
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts
index a5bf269..9943b58 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts
@@ -52,5 +52,4 @@
   >
     <iron-icon icon="gr-icons:unified"></iron-icon>
   </gr-button>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.js b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.js
index 07a1d16..f554227 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-diff-mode-selector.js';
 import {DiffViewMode} from '../../../constants/constants.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-diff-mode-selector');
 
@@ -40,7 +41,7 @@
   });
 
   test('setMode', () => {
-    const saveStub = sinon.stub(element.$.restAPI, 'savePreferences');
+    const saveStub = stubRestApi('savePreferences');
 
     // Setting the mode initially does not save prefs.
     element.saveOnChange = true;
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-processor/gr-diff-processor.ts b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts
index ab7ab8a..9fafcfa 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
@@ -22,6 +22,7 @@
   GrDiffLineType,
   FILE,
   Highlights,
+  LineNumber,
 } from '../gr-diff/gr-diff-line';
 import {
   GrDiffGroup,
@@ -30,7 +31,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;
@@ -63,6 +64,8 @@
  */
 const MAX_GROUP_SIZE = 120;
 
+const DEBOUNCER_RESET_IS_SCROLLING = 'resetIsScrolling';
+
 /**
  * Converts the API's `DiffContent`s  to `GrDiffGroup`s for rendering.
  *
@@ -122,6 +125,7 @@
   /** @override */
   detached() {
     super.detached();
+    this.cancelDebouncer(DEBOUNCER_RESET_IS_SCROLLING);
     this.cancel();
     this.unlisten(window, 'scroll', '_handleWindowScroll');
   }
@@ -129,7 +133,7 @@
   _handleWindowScroll() {
     this._isScrolling = true;
     this.debounce(
-      'resetIsScrolling',
+      DEBOUNCER_RESET_IS_SCROLLING,
       () => {
         this._isScrolling = false;
       },
@@ -150,7 +154,8 @@
     this.cancel();
 
     this.groups = [];
-    this.push('groups', this._makeFileComments());
+    this.push('groups', this._makeGroup('LOST'));
+    this.push('groups', this._makeGroup(FILE));
 
     // If it's a binary diff, we won't be rendering hunks of text differences
     // so finish processing.
@@ -366,13 +371,16 @@
     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;
     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},
+        left: {start_line: offsetLeft, end_line: offsetLeft + chunk.skip - 1},
+        right: {
+          start_line: offsetRight,
+          end_line: offsetRight + chunk.skip - 1,
+        },
       };
     }
     return group;
@@ -447,10 +455,10 @@
     return line;
   }
 
-  _makeFileComments() {
+  _makeGroup(number: LineNumber) {
     const line = new GrDiffLine(GrDiffLineType.BOTH);
-    line.beforeNumber = FILE;
-    line.afterNumber = FILE;
+    line.beforeNumber = number;
+    line.afterNumber = number;
     return new GrDiffGroup(GrDiffGroupType.BOTH, [line]);
   }
 
@@ -679,15 +687,18 @@
    */
   _breakdownChunk(chunk: DiffContent): DiffContent[] {
     let key: 'a' | 'b' | 'ab' | null = null;
-    if (chunk.a && !chunk.b) {
+    const {a, b, ab, move_details} = chunk;
+    if (a?.length && !b?.length) {
       key = 'a';
-    } else if (chunk.b && !chunk.a) {
+    } else if (b?.length && !a?.length) {
       key = 'b';
-    } else if (chunk.ab) {
+    } else if (ab?.length) {
       key = 'ab';
     }
 
-    if (!key) {
+    // Move chunks should not be divided because of move label
+    // positioned in the top of the chunk
+    if (!key || move_details) {
       return [chunk];
     }
 
@@ -697,8 +708,8 @@
       if (chunk.due_to_rebase) {
         subChunk.due_to_rebase = true;
       }
-      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 ce7a3c4..b8f7498 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
@@ -73,7 +73,7 @@
 
       return element.process(content).then(() => {
         const groups = element.groups;
-
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
         assert.equal(groups.length, 4);
 
         let group = groups[0];
@@ -133,6 +133,7 @@
 
       return element.process(content).then(() => {
         const groups = element.groups;
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
 
         assert.equal(groups[0].type, GrDiffGroupType.BOTH);
         assert.equal(groups[0].lines.length, 1);
@@ -153,6 +154,7 @@
 
         return element.process(content).then(() => {
           const groups = element.groups;
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
 
@@ -185,6 +187,7 @@
         await element.process(content);
 
         const groups = element.groups;
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
 
         // group[0] is the file group
 
@@ -202,8 +205,8 @@
         const skipGroup = commonGroup.contextGroups[1];
         assert.equal(skipGroup.skip, 43900);
         const expectedRange = {
-          left: {start: 21, end: 43920},
-          right: {start: 21, end: 43920},
+          left: {start_line: 21, end_line: 43920},
+          right: {start_line: 21, end_line: 43920},
         };
         assert.deepEqual(skipGroup.lineRange, expectedRange);
 
@@ -231,6 +234,7 @@
 
         return element.process(content).then(() => {
           const groups = element.groups;
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
 
@@ -252,6 +256,7 @@
 
         return element.process(content).then(() => {
           const groups = element.groups;
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
           // group[1] is the "a" group
@@ -283,6 +288,7 @@
 
         return element.process(content).then(() => {
           const groups = element.groups;
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
           // group[1] is the "a" group
@@ -324,6 +330,7 @@
 
         return element.process(content).then(() => {
           const groups = element.groups;
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
           // group[1] is the "a" group
@@ -411,6 +418,7 @@
 
         return element.process(content).then(() => {
           const groups = element.groups;
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
           // group[1] is the "a" group
@@ -450,6 +458,7 @@
 
         return element.process(content).then(() => {
           const groups = element.groups;
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
           // group[1] is the "a" group
@@ -479,6 +488,7 @@
       await element.process(content);
 
       const groups = element.groups;
+      groups.shift(); // remove portedThreadsWithoutRangeGroup
 
       // group[0] is the file group
       // group[1] is the chunk with a
@@ -499,8 +509,8 @@
       const skipGroup = commonGroup.contextGroups[1];
       assert.equal(skipGroup.skip, 60);
       const expectedRange = {
-        left: {start: 22, end: 81},
-        right: {start: 21, end: 80},
+        left: {start_line: 22, end_line: 81},
+        right: {start_line: 21, end_line: 80},
       };
       assert.deepEqual(skipGroup.lineRange, expectedRange);
 
@@ -590,6 +600,43 @@
       assert.deepEqual(result[1].ab, content[0].ab.slice(120));
     });
 
+    test('breaks down added chunks', () => {
+      const size = 120 * 2 + 5;
+      const content = _.times(size, () => `${Math.random()}`);
+      element.context = 5;
+      const splitContent = element._splitLargeChunks([{a: [], b: content}])
+          .map(r => r.b);
+      assert.equal(splitContent.length, 3);
+      assert.deepEqual(splitContent[0], content.slice(0, 5));
+      assert.deepEqual(splitContent[1], content.slice(5, 125));
+      assert.deepEqual(splitContent[2], content.slice(125));
+    });
+
+    test('breaks down removed chunks', () => {
+      const size = 120 * 2 + 5;
+      const content = _.times(size, () => `${Math.random()}`);
+      element.context = 5;
+      const splitContent = element._splitLargeChunks([{a: content, b: []}])
+          .map(r => r.a);
+      assert.equal(splitContent.length, 3);
+      assert.deepEqual(splitContent[0], content.slice(0, 5));
+      assert.deepEqual(splitContent[1], content.slice(5, 125));
+      assert.deepEqual(splitContent[2], content.slice(125));
+    });
+
+    test('does not break down moved chunks', () => {
+      const size = 120 * 2 + 5;
+      const content = _.times(size, () => `${Math.random()}`);
+      element.context = 5;
+      const splitContent = element._splitLargeChunks([{
+        a: content,
+        b: [],
+        move_details: {changed: false},
+      }]).map(r => r.a);
+      assert.equal(splitContent.length, 1);
+      assert.deepEqual(splitContent[0], content);
+    });
+
     test('does not break-down common chunks w/ context', () => {
       const content = [{
         ab: _.times(75, () => `${Math.random()}`),
@@ -707,12 +754,12 @@
       element._isScrolling = true;
       element.process(content);
       // Just the files group - no more processing during scrolling.
-      assert.equal(element.groups.length, 1);
+      assert.equal(element.groups.length, 2);
 
       element._isScrolling = false;
       element.process(content);
       // More groups have been processed. How many does not matter here.
-      assert.isAtLeast(element.groups.length, 2);
+      assert.isAtLeast(element.groups.length, 3);
     });
 
     test('image diffs', () => {
@@ -725,7 +772,7 @@
       const content = _.times(200, _.constant(contentRow));
       sinon.stub(element, 'async');
       element.process(content, true);
-      assert.equal(element.groups.length, 1);
+      assert.equal(element.groups.length, 2);
 
       // Image diffs don't process content, just the 'FILE' line.
       assert.equal(element.groups[0].lines.length, 1);
@@ -797,20 +844,26 @@
         assert.deepEqual(
             skippedGroup.lineRange,
             {
-              left: {start: lineNums.left + 1, end: lineNums.left + skip},
-              right: {start: lineNums.right + 1, end: lineNums.right + skip},
+              left: {
+                start_line: lineNums.left + 1,
+                end_line: lineNums.left + skip,
+              },
+              right: {
+                start_line: lineNums.right + 1,
+                end_line: lineNums.right + skip,
+              },
             });
 
         assert.deepEqual(
             abGroup.lineRange,
             {
               left: {
-                start: lineNums.left + skip + 1,
-                end: lineNums.left + skip + rows.length,
+                start_line: lineNums.left + skip + 1,
+                end_line: lineNums.left + skip + rows.length,
               },
               right: {
-                start: lineNums.right + skip + 1,
-                end: lineNums.right + skip + rows.length,
+                start_line: lineNums.right + skip + 1,
+                end_line: lineNums.right + skip + rows.length,
               },
             });
       });
@@ -1019,16 +1072,6 @@
             }
           });
 
-      test('_breakdownChunk keeps due_to_move for broken down additions',
-          () => {
-            sinon.spy(element, '_breakdown');
-            const chunk = {b: ['blah', 'blah', 'blah'], due_to_move: true};
-            const result = element._breakdownChunk(chunk);
-            for (const subResult of result) {
-              assert.isTrue(subResult.due_to_move);
-            }
-          });
-
       test('_breakdown common case', () => {
         const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'
             .split(' ');
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..9493478 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,9 +27,10 @@
 } 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';
+import {getSide, isThreadEl} from '../gr-diff/gr-diff-utils';
 
 /**
  * Possible CSS classes indicating the state of selection. Dynamically added/
@@ -96,10 +97,10 @@
   }
 
   _handleDownOnRangeComment(node: Element) {
-    if (node?.nodeName?.toLowerCase() === 'gr-comment-thread') {
+    if (isThreadEl(node)) {
       this._setClasses([
         SelectionClass.COMMENT,
-        node.getAttribute('comment-side') === Side.LEFT
+        getSide(node) === Side.LEFT
           ? SelectionClass.LEFT
           : SelectionClass.RIGHT,
       ]);
@@ -198,7 +199,7 @@
     if (!diffHosts.length) return window.getSelection();
 
     const curDiffHost = diffHosts.find(diffHost => {
-      if (!diffHost || !diffHost.shadowRoot) return false;
+      if (!diffHost?.shadowRoot?.getSelection) return false;
       const selection = diffHost.shadowRoot.getSelection();
       // Pick the one with valid selection:
       // https://developer.mozilla.org/en-US/docs/Web/API/Selection/type
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 fc46123..2c4b8f6 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
@@ -21,7 +21,6 @@
 import '../../shared/gr-dropdown/gr-dropdown';
 import '../../shared/gr-dropdown-list/gr-dropdown-list';
 import '../../shared/gr-icons/gr-icons';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-select/gr-select';
 import '../../shared/revision-info/revision-info';
 import '../gr-comment-api/gr-comment-api';
@@ -40,13 +39,11 @@
   KeyboardShortcutMixin,
   Shortcut,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
-import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter';
-import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {appContext} from '../../../services/app-context';
 import {
   computeAllPatchSets,
   computeLatestPatchNum,
-  patchNumEquals,
   PatchSet,
 } from '../../../utils/patch-set-util';
 import {
@@ -58,25 +55,18 @@
 } from '../../../utils/path-list-util';
 import {changeBaseURL, changeIsOpen} from '../../../utils/change-util';
 import {customElement, observe, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GrDiffHost} from '../gr-diff-host/gr-diff-host';
 import {
   DropdownItem,
   GrDropdownList,
 } from '../../shared/gr-dropdown-list/gr-dropdown-list';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {
-  ChangeComments,
-  GrCommentApi,
-  TwoSidesComments,
-} from '../gr-comment-api/gr-comment-api';
+import {ChangeComments, GrCommentApi} from '../gr-comment-api/gr-comment-api';
 import {GrDiffModeSelector} from '../gr-diff-mode-selector/gr-diff-mode-selector';
 import {
   ChangeInfo,
   CommitId,
   ConfigInfo,
-  DiffInfo,
-  DiffPreferencesInfo,
   EditInfo,
   EditPatchSetNum,
   ElementPropertyDeepChange,
@@ -89,19 +79,25 @@
   RepoName,
   RevisionInfo,
 } from '../../../types/common';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {ChangeViewState, CommitRange, FileRange} from '../../../types/types';
 import {FilesWebLinks} from '../gr-patch-range-select/gr-patch-range-select';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {GrDiffCursor} from '../gr-diff-cursor/gr-diff-cursor';
 import {CommentSide, DiffViewMode, Side} from '../../../constants/constants';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {GrApplyFixDialog} from '../gr-apply-fix-dialog/gr-apply-fix-dialog';
 import {LineOfInterest} from '../gr-diff/gr-diff';
 import {RevisionInfo as RevisionInfoObj} from '../../shared/revision-info/revision-info';
-import {CommentMap} from '../../../utils/comment-util';
+import {
+  CommentMap,
+  isInBaseOfPatchRange,
+  getPatchRangeForCommentUrl,
+} from '../../../utils/comment-util';
 import {AppElementParams} from '../../gr-app-types';
 import {CustomKeyboardEvent, OpenFixPreviewEvent} from '../../../types/events';
-
+import {fireAlert, fireTitleChange} from '../../../utils/event-util';
+import {GerritView} from '../../../services/router/router-model';
+import {assertIsDefined} from '../../../utils/common-util';
 const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
 const MSG_LOADING_BLAME = 'Loading blame...';
 const MSG_LOADED_BLAME = 'Blame loaded';
@@ -118,7 +114,6 @@
 
 export interface GrDiffView {
   $: {
-    restAPI: RestApiService & Element;
     commentAPI: GrCommentApi;
     cursor: GrDiffCursor;
     diffHost: GrDiffHost;
@@ -188,9 +183,7 @@
 
   @property({
     type: Array,
-    computed:
-      '_formatFilesForDropdown(_files, ' +
-      '_patchRange.patchNum, _changeComments)',
+    computed: '_formatFilesForDropdown(_files, _patchRange, _changeComments)',
   })
   _formattedFiles?: DropdownItem[];
 
@@ -239,9 +232,6 @@
   @property({type: Object})
   _commentMap?: CommentMap;
 
-  @property({type: Object})
-  _commentsForDiff?: TwoSidesComments;
-
   @property({
     type: Object,
     computed: '_computeCommentSkips(_commentMap, _fileList, _path)',
@@ -302,7 +292,7 @@
       [Shortcut.OPEN_DIFF_PREFS]: '_handleCommaKey',
       [Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
       [Shortcut.TOGGLE_FILE_REVIEWED]: '_throttledToggleFileReviewed',
-      [Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_handleExpandAllDiffContext',
+      [Shortcut.TOGGLE_ALL_DIFF_CONTEXT]: '_handleToggleAllDiffContext',
       [Shortcut.NEXT_UNREVIEWED_FILE]: '_handleNextUnreviewedFile',
       [Shortcut.TOGGLE_BLAME]: '_handleToggleBlame',
       [Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
@@ -324,6 +314,8 @@
 
   flagsService = appContext.flagsService;
 
+  private readonly restApiService = appContext.restApiService;
+
   _throttledToggleFileReviewed?: EventListener;
 
   _onRenderHandler?: EventListener;
@@ -359,19 +351,19 @@
   }
 
   _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
+    return this.restApiService.getLoggedIn();
   }
 
   @observe('_change.project')
   _getProjectConfig(project?: RepoName) {
     if (!project) return;
-    return this.$.restAPI.getProjectConfig(project).then(config => {
+    return this.restApiService.getProjectConfig(project).then(config => {
       this._projectConfig = config;
     });
   }
 
   _getChangeDetail(changeNum: NumericChangeId) {
-    return this.$.restAPI.getDiffChangeDetail(changeNum).then(change => {
+    return this.restApiService.getDiffChangeDetail(changeNum).then(change => {
       if (!change) throw new Error('Missing "change" in API response.');
       this._change = change;
       return change;
@@ -379,8 +371,8 @@
   }
 
   _getChangeEdit() {
-    if (!this._changeNum) throw new Error('Missing this._changeNum');
-    return this.$.restAPI.getChangeEdit(this._changeNum);
+    assertIsDefined(this._changeNum, '_changeNum');
+    return this.restApiService.getChangeEdit(this._changeNum);
   }
 
   _getSortedFileList(files?: Files) {
@@ -418,7 +410,7 @@
     }
 
     const patchRange = patchRangeRecord.base;
-    return this.$.restAPI
+    return this.restApiService
       .getChangeFiles(changeNum, patchRange)
       .then(changeFiles => {
         if (!changeFiles) return;
@@ -433,13 +425,13 @@
   }
 
   _getDiffPreferences() {
-    return this.$.restAPI.getDiffPreferences().then(prefs => {
+    return this.restApiService.getDiffPreferences().then(prefs => {
       this._prefs = prefs;
     });
   }
 
   _getPreferences() {
-    return this.$.restAPI.getPreferences();
+    return this.restApiService.getPreferences();
   }
 
   _getWindowWidth() {
@@ -457,13 +449,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,
-        })
-      );
+      fireAlert(this, ERR_REVIEW_STATUS);
       throw err;
     });
   }
@@ -472,7 +458,7 @@
     if (!this._changeNum) return Promise.resolve(undefined);
     if (!this._patchRange?.patchNum) return Promise.resolve(undefined);
     if (!this._path) return Promise.resolve(undefined);
-    return this.$.restAPI.saveFileReviewed(
+    return this.restApiService.saveFileReviewed(
       this._changeNum,
       this._patchRange?.patchNum,
       this._path,
@@ -605,6 +591,7 @@
     if (this.modifierPressed(e)) return;
 
     e.preventDefault();
+    this.classList.remove('hideComments');
     this.$.cursor.createCommentInPlace();
   }
 
@@ -725,7 +712,12 @@
     );
   }
 
-  _navToFile(path: string, fileList: string[], direction: -1 | 1) {
+  _navToFile(
+    path: string,
+    fileList: string[],
+    direction: -1 | 1,
+    navigateToFirstComment?: boolean
+  ) {
     const newPath = this._getNavLinkPath(path, fileList, direction);
     if (!newPath) return;
     if (!this._change) return;
@@ -741,11 +733,18 @@
     }
 
     if (!newPath.path) return;
+    let lineNum;
+    if (navigateToFirstComment)
+      lineNum = this._changeComments?.getCommentsForPath(
+        newPath.path,
+        this._patchRange
+      )?.[0].line;
     GerritNav.navigateToDiff(
       this._change,
       newPath.path,
       this._patchRange.patchNum,
-      this._patchRange.basePatchNum
+      this._patchRange.basePatchNum,
+      lineNum
     );
   }
 
@@ -754,8 +753,6 @@
    * @param fileList The list of files in this change and
    * patch range.
    * @param direction Either 1 (next file) or -1 (prev file).
-   * @param opt_noUp Whether to return to the change view
-   * when advancing the file goes outside the bounds of fileList.
    * @return The next URL when proceeding in the specified
    * direction.
    */
@@ -763,15 +760,14 @@
     change?: ChangeInfo,
     path?: string,
     fileList?: string[],
-    direction?: -1 | 1,
-    opt_noUp?: boolean
+    direction?: -1 | 1
   ) {
     if (!change) return null;
     if (!path) return null;
     if (!fileList) return null;
     if (!direction) return null;
 
-    const newPath = this._getNavLinkPath(path, fileList, direction, opt_noUp);
+    const newPath = this._getNavLinkPath(path, fileList, direction);
     if (!newPath) {
       return null;
     }
@@ -816,15 +812,8 @@
    * @param fileList The list of files in this change and
    * patch range.
    * @param direction Either 1 (next file) or -1 (prev file).
-   * @param opt_noUp Whether to return to the change view
-   * when advancing the file goes outside the bounds of fileList.
    */
-  _getNavLinkPath(
-    path: string,
-    fileList: string[],
-    direction: -1 | 1,
-    opt_noUp?: boolean
-  ) {
+  _getNavLinkPath(path: string, fileList: string[], direction: -1 | 1) {
     if (!path || !fileList || fileList.length === 0) {
       return null;
     }
@@ -839,9 +828,6 @@
     // Redirect to the change view if opt_noUp isn’t truthy and idx falls
     // outside the bounds of [0, fileList.length).
     if (idx < 0 || idx > fileList.length - 1) {
-      if (opt_noUp) {
-        return null;
-      }
       return {up: true};
     }
 
@@ -853,10 +839,12 @@
     patchNum?: PatchSetNum
   ): Promise<Set<string>> {
     if (!changeNum || !patchNum) return Promise.resolve(new Set<string>());
-    return this.$.restAPI.getReviewedFiles(changeNum, patchNum).then(files => {
-      this._reviewedFiles = new Set(files);
-      return this._reviewedFiles;
-    });
+    return this.restApiService
+      .getReviewedFiles(changeNum, patchNum)
+      .then(files => {
+        this._reviewedFiles = new Set(files);
+        return this._reviewedFiles;
+      });
   }
 
   _getReviewedStatus(
@@ -880,51 +868,36 @@
 
   _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,
-      })
+    fireAlert(
+      this,
+      `Patchset ${this._patchRange.basePatchNum} vs ` +
+        `${this._patchRange.patchNum} selected. Press v + \u2190 to view ` +
+        `Base vs ${this._patchRange.basePatchNum}`
     );
   }
 
   _displayDiffAgainstLatestToast(latestPatchNum?: PatchSetNum) {
     if (!this._patchRange) return;
-    const leftPatchset = patchNumEquals(
-      this._patchRange.basePatchNum,
-      ParentPatchSetNum
-    )
-      ? 'Base'
-      : `Patchset ${this._patchRange.basePatchNum}`;
-    this.dispatchEvent(
-      new CustomEvent('show-alert', {
-        detail: {
-          // \u2191 = ↑
-          message: `${leftPatchset} vs
+    const leftPatchset =
+      this._patchRange.basePatchNum === ParentPatchSetNum
+        ? 'Base'
+        : `Patchset ${this._patchRange.basePatchNum}`;
+    fireAlert(
+      this,
+      `${leftPatchset} vs
             ${this._patchRange.patchNum} selected\n. Press v + \u2191 to view
-            ${leftPatchset} vs Patchset ${latestPatchNum}`,
-        },
-        composed: true,
-        bubbles: true,
-      })
+            ${leftPatchset} vs Patchset ${latestPatchNum}`
     );
   }
 
   _displayToasts() {
     if (!this._patchRange) return;
-    if (!patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+    if (this._patchRange.basePatchNum !== ParentPatchSetNum) {
       this._displayDiffBaseAgainstLeftToast();
       return;
     }
     const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (!patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+    if (this._patchRange.patchNum !== latestPatchNum) {
       this._displayDiffAgainstLatestToast(latestPatchNum);
       return;
     }
@@ -935,27 +908,43 @@
     let baseCommit: CommitId | undefined;
     if (!this._change) return;
     if (!this._patchRange || !this._patchRange.patchNum) return;
-    for (const commitSha in this._change.revisions) {
-      if (!hasOwnProperty(this._change.revisions, commitSha)) continue;
-      const revision = this._change.revisions[commitSha];
+    const revisions = this._change.revisions ?? {};
+    for (const [commitSha, revision] of Object.entries(revisions)) {
       const patchNum = revision._number;
-      if (patchNumEquals(patchNum, this._patchRange.patchNum)) {
+      if (patchNum === this._patchRange.patchNum) {
         commit = commitSha as CommitId;
         const commitObj = revision.commit;
         const parents = commitObj?.parents || [];
         if (
-          patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum) &&
+          this._patchRange.basePatchNum === ParentPatchSetNum &&
           parents.length
         ) {
           baseCommit = parents[parents.length - 1].commit;
         }
-      } else if (patchNumEquals(patchNum, this._patchRange.basePatchNum)) {
+      } else if (patchNum === this._patchRange.basePatchNum) {
         baseCommit = commitSha as CommitId;
       }
     }
     this._commitRange = commit && baseCommit ? {commit, baseCommit} : undefined;
   }
 
+  _updateUrlToDiffUrl(lineNum?: number, leftSide?: boolean) {
+    if (!this._change) return;
+    if (!this._patchRange) return;
+    if (!this._changeNum) return;
+    if (!this._path) return;
+    const url = GerritNav.getUrlForDiffById(
+      this._changeNum,
+      this._change.project,
+      this._path,
+      this._patchRange.patchNum,
+      this._patchRange.basePatchNum,
+      lineNum,
+      leftSide
+    );
+    history.replaceState(null, '', url);
+  }
+
   _initPatchRange() {
     let leftSide = false;
     if (!this._change) return;
@@ -965,37 +954,17 @@
         this.params.commentId
       );
       if (!comment) {
-        this.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {
-              message: 'comment not found',
-            },
-            composed: true,
-            bubbles: true,
-          })
-        );
+        fireAlert(this, 'comment not found');
         GerritNav.navigateToChange(this._change);
         return;
       }
       this._path = comment.path;
+
       const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-      if (!comment.patch_set) throw new Error('Missing comment.patch_set');
       if (!latestPatchNum) throw new Error('Missing _allPatchSets');
-      if (patchNumEquals(latestPatchNum, comment.patch_set)) {
-        this._patchRange = {
-          patchNum: latestPatchNum,
-          basePatchNum: ParentPatchSetNum,
-        };
-        leftSide = comment.__commentSide === 'left';
-      } else {
-        this._patchRange = {
-          patchNum: latestPatchNum,
-          basePatchNum: comment.patch_set,
-        };
-        // comment is now on the left side since we are showing
-        // comment.patch_set vs latest
-        leftSide = true;
-      }
+      this._patchRange = getPatchRangeForCommentUrl(comment, latestPatchNum);
+      leftSide = isInBaseOfPatchRange(comment, this._patchRange);
+
       this._focusLineNum = comment.line;
     } else {
       if (this.params.path) {
@@ -1012,15 +981,15 @@
         leftSide = !!this.params.leftSide;
       }
     }
-    if (!this._patchRange) throw new Error('Failed to initialize patchRange.');
+    assertIsDefined(this._patchRange, '_patchRange');
     this._initLineOfInterestAndCursor(leftSide);
-    this._commentMap = this._getPaths(this._patchRange);
 
-    this._commentsForDiff = this._getCommentsForPath(
-      this._path,
-      this._patchRange,
-      this._projectConfig
-    );
+    if (this.params?.commentId) {
+      // url is of type /comment/{commentId} which isn't meaningful
+      this._updateUrlToDiffUrl(this._focusLineNum, leftSide);
+    }
+
+    this._commentMap = this._getPaths(this._patchRange);
   }
 
   _isFileUnchanged(diff: DiffInfo) {
@@ -1045,7 +1014,7 @@
     this._focusLineNum = undefined;
 
     if (value.changeNum && value.project) {
-      this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
+      this.restApiService.setInProjectLookup(value.changeNum, value.project);
     }
 
     this._changeNum = value.changeNum;
@@ -1071,7 +1040,7 @@
     );
 
     promises.push(this._getChangeDetail(this._changeNum));
-    promises.push(this._loadComments());
+    promises.push(this._loadComments(value.patchNum));
 
     promises.push(this._getChangeEdit());
 
@@ -1083,7 +1052,18 @@
         this._loading = false;
         this._initPatchRange();
         this._initCommitRange();
-        this.$.diffHost.comments = this._commentsForDiff;
+
+        assertIsDefined(this._path, '_path');
+        if (!this._changeComments)
+          throw new Error('change comments must be defined');
+        assertIsDefined(this._patchRange, '_patchRange');
+
+        // TODO(dhruvsri): check if basePath should be set here
+        this.$.diffHost.threads = this._changeComments.getThreadsBySideForFile(
+          {path: this._path},
+          this._patchRange
+        );
+
         const edit = r[4] as EditInfo | undefined;
         if (edit) {
           this.set(`_change.revisions.${edit.commit.commit}`, {
@@ -1103,9 +1083,9 @@
         if (!this._diff) throw new Error('Missing this._diff');
         const fileUnchanged = this._isFileUnchanged(this._diff);
         if (fileUnchanged && value.commentLink) {
-          if (!this._change) throw new Error('Missing this._change');
-          if (!this._path) throw new Error('Missing this._path');
-          if (!this._patchRange) throw new Error('Missing this._patchRange');
+          assertIsDefined(this._change, '_change');
+          assertIsDefined(this._path, '_path');
+          assertIsDefined(this._patchRange, '_patchRange');
 
           if (this._patchRange.basePatchNum === ParentPatchSetNum) {
             // file is unchanged between Base vs X
@@ -1113,17 +1093,12 @@
             return;
           }
 
-          this.dispatchEvent(
-            new CustomEvent('show-alert', {
-              detail: {
-                message: `File is unchanged between Patchset
+          fireAlert(
+            this,
+            `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,
@@ -1146,7 +1121,7 @@
   _changeViewStateChanged(changeViewState: Partial<ChangeViewState>) {
     if (changeViewState.diffMode === null) {
       // If screen size is small, always default to unified view.
-      this.$.restAPI.getPreferences().then(prefs => {
+      this.restApiService.getPreferences().then(prefs => {
         if (prefs) {
           this.set('changeViewState.diffMode', prefs.default_diff_view);
         }
@@ -1221,13 +1196,7 @@
 
   _pathChanged(path: string) {
     if (path) {
-      this.dispatchEvent(
-        new CustomEvent('title-change', {
-          detail: {title: computeTruncatedPath(path)},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireTitleChange(this, computeTruncatedPath(path));
     }
 
     if (!this._fileList || this._fileList.length === 0) return;
@@ -1276,7 +1245,7 @@
     if (!patchRange) return {patchNum, basePatchNum};
     if (
       patchRange.basePatchNum !== ParentPatchSetNum ||
-      !patchNumEquals(patchRange.patchNum, latestPatchNum as PatchSetNum)
+      patchRange.patchNum !== latestPatchNum
     ) {
       patchNum = patchRange.patchNum;
       basePatchNum = patchRange.basePatchNum;
@@ -1321,11 +1290,11 @@
 
   _formatFilesForDropdown(
     files?: Files,
-    patchNum?: PatchSetNum,
+    patchRange?: PatchRange,
     changeComments?: ChangeComments
   ): DropdownItem[] {
     if (!files) return [];
-    if (!patchNum) return [];
+    if (!patchRange) return [];
     if (!changeComments) return [];
 
     const dropdownContent: DropdownItem[] = [];
@@ -1334,51 +1303,18 @@
         text: computeDisplayPath(path),
         mobileText: computeTruncatedPath(path),
         value: path,
-        bottomText: this._computeCommentString(
-          changeComments,
-          patchNum,
+        bottomText: changeComments.computeCommentsString(
+          patchRange,
           path,
-          files.changeFilesByPath[path]
+          files.changeFilesByPath[path],
+          /* includeUnmodified= */ true
         ),
+        file: {...files.changeFilesByPath[path], __path: path},
       });
     }
     return dropdownContent;
   }
 
-  _computeCommentString(
-    changeComments?: ChangeComments,
-    patchNum?: PatchSetNum,
-    path?: string,
-    changeFileInfo?: FileInfo
-  ) {
-    if (!changeComments) return '';
-    if (!path) return '';
-    if (!changeFileInfo) return '';
-
-    const unresolvedCount = changeComments.computeUnresolvedNum({
-      patchNum,
-      path,
-    });
-    const commentThreadCount = changeComments.computeCommentThreadCount({
-      patchNum,
-      path,
-    });
-    const commentThreadString = GrCountStringFormatter.computePluralString(
-      commentThreadCount,
-      'comment'
-    );
-    const unresolvedString = GrCountStringFormatter.computeString(
-      unresolvedCount,
-      'unresolved'
-    );
-
-    const unmodifiedString = changeFileInfo.status === 'U' ? 'no changes' : '';
-
-    return [unmodifiedString, commentThreadString, unresolvedString]
-      .filter(v => v && v.length > 0)
-      .join(', ');
-  }
-
   _computePrefsButtonHidden(
     prefs?: DiffPreferencesInfo,
     prefsDisabled?: boolean
@@ -1411,8 +1347,8 @@
 
     const {basePatchNum, patchNum} = e.detail;
     if (
-      patchNumEquals(basePatchNum, this._patchRange.basePatchNum) &&
-      patchNumEquals(patchNum, this._patchRange.patchNum)
+      basePatchNum === this._patchRange.basePatchNum &&
+      patchNum === this._patchRange.patchNum
     ) {
       return;
     }
@@ -1455,26 +1391,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(
@@ -1543,9 +1465,9 @@
   ) {
     let patchNum = patchRange.patchNum;
 
-    const comparedAgainsParent = patchRange.basePatchNum === 'PARENT';
+    const comparedAgainstParent = patchRange.basePatchNum === 'PARENT';
 
-    if (isBase && !comparedAgainsParent) {
+    if (isBase && !comparedAgainstParent) {
       patchNum = patchRange.basePatchNum;
     }
 
@@ -1553,7 +1475,7 @@
       changeBaseURL(project, changeNum, patchNum) +
       `/files/${encodeURIComponent(path)}/download`;
 
-    if (isBase && comparedAgainsParent) {
+    if (isBase && comparedAgainstParent) {
       url += '?parent=1';
     }
 
@@ -1571,11 +1493,13 @@
     return url;
   }
 
-  _loadComments() {
-    if (!this._changeNum) throw new Error('Missing this._changeNum');
-    return this.$.commentAPI.loadAll(this._changeNum).then(comments => {
-      this._changeComments = comments;
-    });
+  _loadComments(patchSet?: PatchSetNum) {
+    assertIsDefined(this._changeNum, '_changeNum');
+    return this.$.commentAPI
+      .loadAll(this._changeNum, patchSet)
+      .then(comments => {
+        this._changeComments = comments;
+      });
   }
 
   @observe('_files.changeFilesByPath', '_path', '_patchRange', '_projectConfig')
@@ -1593,13 +1517,10 @@
 
     const file = files[path];
     if (file && file.old_path) {
-      this._commentsForDiff = this._changeComments.getCommentsBySideForFile(
+      this.$.diffHost.threads = this._changeComments.getThreadsBySideForFile(
         {path, basePath: file.old_path},
-        patchRange,
-        projectConfig
+        patchRange
       );
-
-      this.$.diffHost.comments = this._commentsForDiff;
     }
   }
 
@@ -1608,26 +1529,10 @@
     return this._changeComments.getPaths(patchRange);
   }
 
-  _getCommentsForPath(
-    path?: string,
-    patchRange?: PatchRange,
-    projectConfig?: ConfigInfo
-  ) {
-    if (!path) return undefined;
-    if (!patchRange) return undefined;
-    if (!this._changeComments) return undefined;
-
-    return this._changeComments.getCommentsBySideForPath(
-      path,
-      patchRange,
-      projectConfig
-    );
-  }
-
   _getDiffDrafts() {
-    if (!this._changeNum) throw new Error('Missing this._changeNum');
+    assertIsDefined(this._changeNum, '_changeNum');
 
-    return this.$.restAPI.getDiffDrafts(this._changeNum);
+    return this.restApiService.getDiffDrafts(this._changeNum);
   }
 
   _computeCommentSkips(
@@ -1672,7 +1577,7 @@
     patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange>
   ) {
     const patchRange = patchRangeRecord.base || {};
-    return patchNumEquals(patchRange.patchNum, EditPatchSetNum);
+    return patchRange.patchNum === EditPatchSetNum;
   }
 
   _computeBlameToggleLabel(loaded?: boolean, loading?: boolean) {
@@ -1681,24 +1586,12 @@
 
   _loadBlame() {
     this._isBlameLoading = true;
-    this.dispatchEvent(
-      new CustomEvent('show-alert', {
-        detail: {message: MSG_LOADING_BLAME},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireAlert(this, 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,
-          })
-        );
+        fireAlert(this, MSG_LOADED_BLAME);
       })
       .catch(() => {
         this._isBlameLoading = false;
@@ -1743,16 +1636,8 @@
     if (!this._path) return;
     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,
-        })
-      );
+    if (this._patchRange.basePatchNum === ParentPatchSetNum) {
+      fireAlert(this, 'Base is already selected.');
       return;
     }
     GerritNav.navigateToDiff(
@@ -1768,16 +1653,8 @@
     if (!this._path) return;
     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,
-        })
-      );
+    if (this._patchRange.basePatchNum === ParentPatchSetNum) {
+      fireAlert(this, 'Left is already base.');
       return;
     }
     GerritNav.navigateToDiff(
@@ -1798,16 +1675,8 @@
     if (!this._patchRange) return;
 
     const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'Latest is already selected.',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+    if (this._patchRange.patchNum === latestPatchNum) {
+      fireAlert(this, 'Latest is already selected.');
       return;
     }
 
@@ -1826,16 +1695,8 @@
     if (!this._patchRange) return;
 
     const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'Right is already latest.',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+    if (this._patchRange.patchNum === latestPatchNum) {
+      fireAlert(this, 'Right is already latest.');
       return;
     }
     GerritNav.navigateToDiff(
@@ -1854,18 +1715,10 @@
 
     const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
     if (
-      patchNumEquals(this._patchRange.patchNum, latestPatchNum) &&
-      patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)
+      this._patchRange.patchNum === latestPatchNum &&
+      this._patchRange.basePatchNum === ParentPatchSetNum
     ) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'Already diffing base against latest.',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, 'Already diffing base against latest.');
       return;
     }
     GerritNav.navigateToDiff(this._change, this._path, latestPatchNum);
@@ -1892,10 +1745,10 @@
     return '';
   }
 
-  _handleExpandAllDiffContext(e: CustomKeyboardEvent) {
+  _handleToggleAllDiffContext(e: CustomKeyboardEvent) {
     if (this.shouldSuppressKeyboardShortcut(e)) return;
 
-    this.$.diffHost.expandAllContext();
+    this.$.diffHost.toggleAllContext();
   }
 
   _computeDiffPrefsDisabled(disableDiffPrefs?: boolean, loggedIn?: boolean) {
@@ -1917,6 +1770,23 @@
     this._navToFile(this._path, unreviewedFiles, 1);
   }
 
+  _navigateToNextFileWithCommentThread() {
+    if (!this._path) return;
+    if (!this._fileList) return;
+    if (!this._patchRange) return;
+    if (!this._change) return;
+    const hasComment = (path: string) => {
+      return (
+        this._changeComments?.getCommentsForPath(path, this._patchRange!)
+          ?.length ?? 0 > 0
+      );
+    };
+    const filesWithComments = this._fileList.filter(
+      file => file === this._path || hasComment(file)
+    );
+    this._navToFile(this._path, filesWithComments, 1, true);
+  }
+
   _handleReloadingDiffPreference() {
     this._getDiffPreferences();
   }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
index be2dce5..bfb0cd8 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
@@ -255,7 +255,7 @@
           class="navLink"
           title="[[createTitle(Shortcut.PREV_FILE,
                     ShortcutSection.NAVIGATION)]]"
-          href$="[[_computeNavLinkURL(_change, _path, _fileList, -1, 1)]]"
+          href$="[[_computeNavLinkURL(_change, _path, _fileList, -1)]]"
         >
           Prev</a
         >
@@ -273,7 +273,7 @@
           class="navLink"
           title="[[createTitle(Shortcut.NEXT_FILE,
                 ShortcutSection.NAVIGATION)]]"
-          href$="[[_computeNavLinkURL(_change, _path, _fileList, 1, 1)]]"
+          href$="[[_computeNavLinkURL(_change, _path, _fileList, 1)]]"
         >
           Next</a
         >
@@ -376,14 +376,14 @@
     <div class="fileNav mobile">
       <a
         class="mobileNavLink"
-        href$="[[_computeNavLinkURL(_change, _path, _fileList, -1, 1)]]"
+        href$="[[_computeNavLinkURL(_change, _path, _fileList, -1)]]"
       >
         &lt;</a
       >
       <div class="fullFileName mobile">[[_computeDisplayPath(_path)]]</div>
       <a
         class="mobileNavLink"
-        href$="[[_computeNavLinkURL(_change, _path, _fileList, 1, 1)]]"
+        href$="[[_computeNavLinkURL(_change, _path, _fileList, 1)]]"
       >
         &gt;</a
       >
@@ -425,11 +425,10 @@
     on-reload-diff-preference="_handleReloadingDiffPreference"
   >
   </gr-diff-preferences-dialog>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  <gr-storage id="storage"></gr-storage>
   <gr-diff-cursor
     id="cursor"
     on-navigate-to-next-unreviewed-file="_handleNextUnreviewedFile"
+    on-navigate-to-next-file-with-comments="_navigateToNextFileWithCommentThread"
   ></gr-diff-cursor>
   <gr-comment-api id="commentAPI"></gr-comment-api>
 `;
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 9a08723..b5473a3 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
@@ -20,15 +20,16 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {ChangeStatus} from '../../../constants/constants.js';
 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 {ChangeComments, _testOnly_findCommentById, _testOnly_getCommentsForPath} from '../gr-comment-api/gr-comment-api.js';
+import {GerritView} from '../../../services/router/router-model.js';
 import {
   createChange,
   createRevisions,
+  createComment,
 } from '../../../test/test-data-generators.js';
+import {stubRestApi} from '../../../test/test-utils.js';
+import {EditPatchSetNum} from '../../../types/common.js';
 
 const basicFixture = fixtureFromElement('gr-diff-view');
 
@@ -62,7 +63,7 @@
       kb.bindShortcut(Shortcut.OPEN_DIFF_PREFS, ',');
       kb.bindShortcut(Shortcut.TOGGLE_DIFF_MODE, 'm');
       kb.bindShortcut(Shortcut.TOGGLE_FILE_REVIEWED, 'r');
-      kb.bindShortcut(Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x');
+      kb.bindShortcut(Shortcut.TOGGLE_ALL_DIFF_CONTEXT, 'shift+x');
       kb.bindShortcut(Shortcut.EXPAND_ALL_COMMENT_THREADS, 'e');
       kb.bindShortcut(Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, 'h');
       kb.bindShortcut(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, 'shift+e');
@@ -88,41 +89,24 @@
       };
     }
 
+    let getDiffChangeDetailStub;
+    let getReviewedFilesStub;
     setup(async () => {
       clock = sinon.useFakeTimers();
-      sinon.stub(appContext.flagsService, 'isEnabled').returns(true);
-      stub('gr-rest-api-interface', {
-        getConfig() {
-          return Promise.resolve({change: {}});
-        },
-        getLoggedIn() {
-          return Promise.resolve(false);
-        },
-        getProjectConfig() {
-          return Promise.resolve({});
-        },
-        getDiffChangeDetail() {
-          return Promise.resolve({});
-        },
-        getChangeFiles() {
-          return Promise.resolve({});
-        },
-        saveFileReviewed() {
-          return Promise.resolve();
-        },
-        getDiffComments() {
-          return Promise.resolve({});
-        },
-        getDiffRobotComments() {
-          return Promise.resolve({});
-        },
-        getDiffDrafts() {
-          return Promise.resolve({});
-        },
-        getReviewedFiles() {
-          return Promise.resolve([]);
-        },
-      });
+      stubRestApi('getConfig').returns(Promise.resolve({change: {}}));
+      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+      stubRestApi('getProjectConfig').returns(Promise.resolve({}));
+      getDiffChangeDetailStub = stubRestApi('getDiffChangeDetail').returns(
+          Promise.resolve({}));
+      stubRestApi('getChangeFiles').returns(Promise.resolve({}));
+      stubRestApi('saveFileReviewed').returns(Promise.resolve());
+      stubRestApi('getDiffComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
+      stubRestApi('getPortedComments').returns(Promise.resolve({}));
+      getReviewedFilesStub = stubRestApi('getReviewedFiles').returns(
+          Promise.resolve([]));
+
       element = basicFixture.instantiate();
       element._changeNum = '42';
       element._path = 'some/path.txt';
@@ -135,24 +119,27 @@
       sinon.stub(element.$.commentAPI, 'loadAll').returns(Promise.resolve({
         _comments: {'/COMMIT_MSG': [
           {
+            ...createComment(),
             id: 'c1',
             line: 10,
             patch_set: 2,
-            __commentSide: 'left',
             path: '/COMMIT_MSG',
           }, {
+            ...createComment(),
             id: 'c3',
             line: 10,
             patch_set: 'PARENT',
-            __commentSide: 'left',
             path: '/COMMIT_MSG',
           },
         ]},
         computeCommentThreadCount: () => {},
+        computeCommentsString: () => '',
         computeUnresolvedNum: () => {},
         getPaths: () => {},
-        getCommentsBySideForPath: () => {},
+        getThreadsBySideForFile: () => [],
+        getCommentsForPath: _testOnly_getCommentsForPath,
         findCommentById: _testOnly_findCommentById,
+
       }));
       await element._loadComments();
       await flush();
@@ -176,43 +163,51 @@
         basePatchNum: 1,
         path: '/COMMIT_MSG',
       };
+      element._path = '/COMMIT_MSG';
+      element._patchRange = {};
       return element._paramsChanged.returnValues[0].then(() => {
         assert.isTrue(element.reporting.diffViewDisplayed.calledOnce);
       });
     });
 
-    test('comment route', () => {
-      const initLineOfInterestAndCursorStub =
+    suite('comment route', () => {
+      let initLineOfInterestAndCursorStub; let getUrlStub; let replaceStateStub;
+      setup(() => {
+        initLineOfInterestAndCursorStub =
         sinon.stub(element, '_initLineOfInterestAndCursor');
-      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({
-        ...createChange(),
-        revisions: createRevisions(11),
-      }));
-      element.params = {
-        view: GerritNav.View.DIFF,
-        changeNum: '42',
-        commentLink: true,
-        commentId: 'c1',
-      };
-      sinon.stub(element.$.diffHost, '_commentsChanged');
-      sinon.stub(element, '_getCommentsForPath').returns({
-        left: [{id: 'c1', __commentSide: 'left', line: 10}],
-        right: [{id: 'c2', __commentSide: 'right', line: 11}],
+        getUrlStub = sinon.stub(GerritNav, 'getUrlForDiffById');
+        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({
+          ...createChange(),
+          revisions: createRevisions(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);
+
+      test('comment url resolves to comment.patch_set vs latest', () => {
+        element.params = {
+          view: GerritNav.View.DIFF,
+          changeNum: '42',
+          commentLink: true,
+          commentId: 'c1',
+        };
+        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));
+        });
       });
     });
 
@@ -232,6 +227,8 @@
         basePatchNum: 1,
         path: '/COMMIT_MSG',
       };
+      element._path = '/COMMIT_MSG';
+      element._patchRange = {};
       return element._paramsChanged.returnValues[0].then(() => {
         assert.isTrue(element._isBlameLoaded);
         assert.isTrue(element._loadBlame.calledOnce);
@@ -257,7 +254,6 @@
             commentLink: true,
             commentId: 'c1',
           };
-          sinon.stub(element.$.diffHost, '_commentsChanged');
           element._change = {
             ...createChange(),
             revisions: createRevisions(11),
@@ -287,7 +283,6 @@
             commentLink: true,
             commentId: 'c3',
           };
-          sinon.stub(element.$.diffHost, '_commentsChanged');
           element._change = {
             ...createChange(),
             revisions: createRevisions(11),
@@ -333,13 +328,11 @@
       sinon.stub(element, '_loadBlame');
       sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
       sinon.spy(element, '_paramsChanged');
-      element.$.restAPI.getDiffChangeDetail.restore();
-      sinon.stub(element.$.restAPI, 'getDiffChangeDetail')
-          .returns(
-              Promise.resolve({
-                ...createChange(),
-                revisions: createRevisions(11),
-              }));
+      getDiffChangeDetailStub.returns(
+          Promise.resolve({
+            ...createChange(),
+            revisions: createRevisions(11),
+          }));
       element._patchRange = {
         patchNum: 2,
         basePatchNum: 1,
@@ -473,11 +466,53 @@
       assert.equal(element._setReviewed.lastCall.args[0], true);
     });
 
-    test('shift+x shortcut expands all diff context', () => {
-      const expandStub = sinon.stub(element.$.diffHost, 'expandAllContext');
+    test('moveToNextCommentThread navigates to next file', () => {
+      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+      const diffChangeStub = sinon.stub(element, '_navigateToChange');
+      sinon.stub(element.$.cursor, 'isAtEnd').returns(true);
+      element._changeNum = '42';
+      const comment = {
+        'wheatley.md': [{
+          ...createComment(),
+          patch_set: 10,
+          line: 21,
+        }],
+      };
+      element._changeComments = new ChangeComments(comment);
+      element._patchRange = {
+        basePatchNum: PARENT,
+        patchNum: 10,
+      };
+      element._change = {
+        _number: 42,
+        revisions: {
+          a: {_number: 10, commit: {parents: []}},
+        },
+      };
+      element._files = getFilesFromFileList(
+          ['chell.go', 'glados.txt', 'wheatley.md']);
+      element._path = 'glados.txt';
+      element.changeViewState.selectedFileIndex = 1;
+      element._loggedIn = true;
+
+      MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
+      flush();
+      assert.isTrue(diffNavStub.calledWithExactly(
+          element._change, 'wheatley.md', 10, PARENT, 21));
+
+      element._path = 'wheatley.md'; // navigated to next file
+
+      MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
+      flush();
+
+      assert.isTrue(diffChangeStub.called);
+    });
+
+    test('shift+x shortcut toggles all diff context', () => {
+      const toggleStub = sinon.stub(element.$.diffHost, 'toggleAllContext');
       MockInteractions.pressAndReleaseKeyOn(element, 88, 'shift', 'x');
       flush();
-      assert.isTrue(expandStub.called);
+      assert.isTrue(toggleStub.called);
     });
 
     test('diff against base', () => {
@@ -629,14 +664,14 @@
       MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
       assert.isTrue(element._loading);
       assert(diffNavStub.lastCall.calledWithExactly(element._change,
-          'wheatley.md', 10, 5),
+          'wheatley.md', 10, 5, undefined),
       'Should navigate to /c/42/5..10/wheatley.md');
       element._path = 'wheatley.md';
 
       MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert.isTrue(element._loading);
       assert(diffNavStub.lastCall.calledWithExactly(element._change,
-          'glados.txt', 10, 5),
+          'glados.txt', 10, 5, undefined),
       'Should navigate to /c/42/5..10/glados.txt');
       element._path = 'glados.txt';
 
@@ -646,7 +681,8 @@
           element._change,
           'chell.go',
           10,
-          5),
+          5,
+          undefined),
       'Should navigate to /c/42/5..10/chell.go');
       element._path = 'chell.go';
 
@@ -700,13 +736,13 @@
 
       MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
       assert(diffNavStub.lastCall.calledWithExactly(element._change,
-          'wheatley.md', 1, PARENT),
+          'wheatley.md', 1, PARENT, undefined),
       'Should navigate to /c/42/1/wheatley.md');
       element._path = 'wheatley.md';
 
       MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert(diffNavStub.lastCall.calledWithExactly(element._change,
-          'glados.txt', 1, PARENT),
+          'glados.txt', 1, PARENT, undefined),
       'Should navigate to /c/42/1/glados.txt');
       element._path = 'glados.txt';
 
@@ -715,7 +751,8 @@
           element._change,
           'chell.go',
           1,
-          PARENT), 'Should navigate to /c/42/1/chell.go');
+          PARENT,
+          undefined), 'Should navigate to /c/42/1/chell.go');
       element._path = 'chell.go';
 
       changeNavStub.reset();
@@ -820,10 +857,7 @@
     }
 
     test('edit visible only when logged and status NEW', async () => {
-      for (const changeStatus in ChangeStatus) {
-        if (!ChangeStatus.hasOwnProperty(changeStatus)) {
-          continue;
-        }
+      for (const changeStatus of Object.keys(ChangeStatus)) {
         assert.isFalse(await isEditVisibile({loggedIn: false, changeStatus}),
             `loggedIn: false, changeStatus: ${changeStatus}`);
 
@@ -900,44 +934,6 @@
       assert.isTrue(overlayOpenStub.called);
     });
 
-    test('_computeCommentString', done => {
-      const path = '/test';
-      element.$.commentAPI.loadAll().then(comments => {
-        const commentThreadCountStub =
-            sinon.stub(comments, 'computeCommentThreadCount');
-        const unresolvedCountStub =
-            sinon.stub(comments, 'computeUnresolvedNum');
-        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);
-        unresolvedCountStub.withArgs({patchNum: 4, path}).returns(0);
-
-        assert.equal(element._computeCommentString(comments, 1, path, {}),
-            '1 unresolved');
-        assert.equal(
-            element._computeCommentString(comments, 2, path, {status: 'M'}),
-            '1 comment');
-        assert.equal(
-            element._computeCommentString(comments, 2, path, {status: 'U'}),
-            'no changes, 1 comment');
-        assert.equal(
-            element._computeCommentString(comments, 3, path, {status: 'A'}),
-            '2 comments, 2 unresolved');
-        assert.equal(
-            element._computeCommentString(
-                comments, 4, path, {status: 'M'}
-            ), '');
-        assert.equal(
-            element._computeCommentString(comments, 4, path, {status: 'U'}),
-            'no changes');
-        done();
-      });
-    });
-
     suite('url params', () => {
       setup(() => {
         sinon.stub(element, '_getFiles');
@@ -957,9 +953,6 @@
           basePatchNum: PARENT,
           patchNum: 10,
         };
-        // 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(
             ['chell.go', 'glados.txt', 'wheatley.md',
@@ -971,28 +964,43 @@
             mobileText: 'chell.go',
             value: 'chell.go',
             bottomText: '',
+            file: {
+              __path: 'chell.go',
+            },
           }, {
             text: 'glados.txt',
             mobileText: 'glados.txt',
             value: 'glados.txt',
             bottomText: '',
+            file: {
+              __path: 'glados.txt',
+            },
           }, {
             text: 'wheatley.md',
             mobileText: 'wheatley.md',
             value: 'wheatley.md',
             bottomText: '',
+            file: {
+              __path: 'wheatley.md',
+            },
           },
           {
             text: 'Commit message',
             mobileText: 'Commit message',
             value: '/COMMIT_MSG',
             bottomText: '',
+            file: {
+              __path: '/COMMIT_MSG',
+            },
           },
           {
             text: 'Merge list',
             mobileText: 'Merge list',
             value: '/MERGE_LIST',
             bottomText: '',
+            file: {
+              __path: '/MERGE_LIST',
+            },
           },
         ];
 
@@ -1027,10 +1035,10 @@
         assert.equal(linkEls[0].getAttribute('href'),
             '42-glados.txt-10-PARENT');
         assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
-        assert.isFalse(linkEls[2].hasAttribute('href'));
+        assert.equal(linkEls[2].getAttribute('href'), '42-undefined-undefined');
         element._path = 'chell.go';
         flush();
-        assert.isFalse(linkEls[0].hasAttribute('href'));
+        assert.equal(linkEls[0].getAttribute('href'), '42-undefined-undefined');
         assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
         assert.equal(linkEls[2].getAttribute('href'),
             '42-glados.txt-10-PARENT');
@@ -1068,10 +1076,11 @@
         flush();
         assert.equal(linkEls[0].getAttribute('href'), '42-glados.txt-10-5');
         assert.equal(linkEls[1].getAttribute('href'), '42-10-5');
-        assert.isFalse(linkEls[2].hasAttribute('href'));
+        assert.equal(linkEls[2].getAttribute('href'), '42-10-5');
         element._path = 'chell.go';
         flush();
-        assert.isFalse(linkEls[0].hasAttribute('href'));
+        assert.equal(linkEls[0].getAttribute('href'),
+            '42-10-5');
         assert.equal(linkEls[1].getAttribute('href'), '42-10-5');
         assert.equal(linkEls[2].getAttribute('href'), '42-glados.txt-10-5');
       });
@@ -1175,7 +1184,7 @@
     test('file review status with edit loaded', () => {
       const saveReviewedStub = sinon.stub(element, '_saveReviewedState');
 
-      element._patchRange = {patchNum: SPECIAL_PATCH_SET_NUM.EDIT};
+      element._patchRange = {patchNum: EditPatchSetNum};
       flush();
 
       assert.isTrue(element._editMode);
@@ -1232,7 +1241,7 @@
       const prefsPromise = new Promise(resolve => {
         resolvePrefs = resolve;
       });
-      sinon.stub(element.$.restAPI, 'getPreferences')
+      stubRestApi('getPreferences')
           .callsFake(() => prefsPromise);
 
       // Attach a new gr-diff-view so we can intercept the preferences fetch.
@@ -1449,7 +1458,6 @@
         await flush();
       });
       test('empty', () => {
-        sinon.stub(element, '_getCommentsForPath');
         sinon.stub(element, '_getPaths').returns(new Map());
         element._initPatchRange();
         assert.equal(Object.keys(element._commentMap).length, 0);
@@ -1461,7 +1469,6 @@
           'path/to/file/one.cpp': [{patch_set: 3, message: 'lorem'}],
           'path-to/file/two.py': [{patch_set: 5, message: 'ipsum'}],
         });
-        sinon.stub(element, '_getCommentsForPath').returns({meta: {}});
         element._changeNum = '42';
         element._patchRange = {
           basePatchNum: 3,
@@ -1623,10 +1630,7 @@
 
     test('_getReviewedStatus', () => {
       const promises = [];
-      element.$.restAPI.getReviewedFiles.restore();
-
-      sinon.stub(element.$.restAPI, 'getReviewedFiles')
-          .returns(Promise.resolve(['path']));
+      getReviewedFilesStub.returns(Promise.resolve(['path']));
 
       promises.push(element._getReviewedStatus(true, null, null, 'path')
           .then(reviewed => assert.isFalse(reviewed)));
@@ -1681,7 +1685,7 @@
         element._patchRange = {patchNum: 1};
         // Reviewed checkbox should be shown.
         assert.isTrue(isVisible(element.$.reviewed));
-        element.set('_patchRange.patchNum', SPECIAL_PATCH_SET_NUM.EDIT);
+        element.set('_patchRange.patchNum', EditPatchSetNum);
         flush();
 
         assert.isFalse(isVisible(element.$.reviewed));
@@ -1690,7 +1694,7 @@
 
     test('_paramsChanged sets in projectLookup', () => {
       sinon.stub(element, '_initLineOfInterestAndCursor');
-      const setStub = sinon.stub(element.$.restAPI, 'setInProjectLookup');
+      const setStub = stubRestApi('setInProjectLookup');
       element._paramsChanged({
         view: GerritNav.View.DIFF,
         changeNum: 101,
@@ -1867,18 +1871,17 @@
         'file1.txt': {},
         'a/b/test.c': {},
       };
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({change: {}}); },
-        getLoggedIn() { return Promise.resolve(false); },
-        getProjectConfig() { return Promise.resolve({}); },
-        getDiffChangeDetail() { return Promise.resolve({}); },
-        getChangeFiles() { return Promise.resolve(changedFiles); },
-        saveFileReviewed() { return Promise.resolve(); },
-        getDiffComments() { return Promise.resolve({}); },
-        getDiffRobotComments() { return Promise.resolve({}); },
-        getDiffDrafts() { return Promise.resolve({}); },
-        getReviewedFiles() { return Promise.resolve([]); },
-      });
+      stubRestApi('getConfig').returns(Promise.resolve({change: {}}));
+      stubRestApi('getLoggedIn').returns(Promise.resolve(true));
+      stubRestApi('getProjectConfig').returns(Promise.resolve({}));
+      stubRestApi('getDiffChangeDetail').returns(Promise.resolve({}));
+      stubRestApi('getChangeFiles').returns(Promise.resolve(changedFiles));
+      stubRestApi('saveFileReviewed').returns(Promise.resolve());
+      stubRestApi('getDiffComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
+      stubRestApi('getReviewedFiles').returns(
+          Promise.resolve([]));
       element = basicFixture.instantiate();
       element._changeNum = '42';
       return element._loadComments();
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 588b9d1..ba6fe7e 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
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 import {BLANK_LINE, GrDiffLine, GrDiffLineType} from './gr-diff-line';
-import {Side} from '../../../constants/constants';
+import {LineRange} from '../../../api/diff';
 
 export enum GrDiffGroupType {
   /** Unchanged context. */
@@ -33,20 +33,6 @@
   right: GrDiffLine;
 }
 
-interface Range {
-  start: number | null;
-  end: number | null;
-}
-
-export interface GrDiffGroupRange {
-  left: Range;
-  right: Range;
-}
-
-export function rangeBySide(range: GrDiffGroupRange, side: Side): Range {
-  return side === Side.LEFT ? range.left : range.right;
-}
-
 /**
  * Hides lines in the given range behind a context control group.
  *
@@ -83,16 +69,17 @@
 
   const numHidden = hiddenEnd - hiddenStart;
 
-  // Only collapse if there is more than 1 line to be hidden.
-  if (numHidden > 1) {
+  // Showing a context control row for less than 4 lines does not make much,
+  // because then that row would consume as much space as the collapsed code.
+  if (numHidden > 3) {
     if (hiddenStart) {
       [before, hidden] = _splitCommonGroups(hidden, hiddenStart);
     }
     if (hiddenEnd) {
       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;
+        const beforeStart = before[0].lineRange.left.start_line;
+        const beforeEnd = before[before.length - 1].lineRange.left.end_line;
         beforeLength = beforeEnd - beforeStart + 1;
       }
       [hidden, after] = _splitCommonGroups(hidden, hiddenEnd - beforeLength);
@@ -136,8 +123,8 @@
     // 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;
+      leftSplit - group.lineRange.left.start_line <
+      group.lineRange.right.end_line - leftSplit;
     if (closerToStartThanEnd) {
       afterSplit = group;
     } else {
@@ -190,18 +177,18 @@
   split: number
 ): GrDiffGroup[][] {
   if (groups.length === 0) return [[], []];
-  const leftSplit = (groups[0].lineRange.left.start || 0) + split;
-  const rightSplit = (groups[0].lineRange.right.start || 0) + split;
+  const leftSplit = groups[0].lineRange.left.start_line + split;
+  const rightSplit = groups[0].lineRange.right.start_line + split;
 
   const beforeGroups = [];
   const afterGroups = [];
   for (const group of groups) {
     const isCompletelyBefore =
-      (group.lineRange.left.end || 0) < leftSplit ||
-      (group.lineRange.right.end || 0) < rightSplit;
+      group.lineRange.left.end_line < leftSplit ||
+      group.lineRange.right.end_line < rightSplit;
     const isCompletelyAfter =
-      leftSplit <= (group.lineRange.left.start || 0) ||
-      rightSplit <= (group.lineRange.right.start || 0);
+      leftSplit <= group.lineRange.left.start_line ||
+      rightSplit <= group.lineRange.right.start_line;
     if (isCompletelyBefore) {
       beforeGroups.push(group);
     } else if (isCompletelyAfter) {
@@ -237,8 +224,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.
@@ -264,9 +249,17 @@
   skip?: number;
 
   /** Both start and end line are inclusive. */
-  lineRange: GrDiffGroupRange = {
-    left: {start: null, end: null},
-    right: {start: null, end: null},
+  lineRange = {
+    left: {start_line: 0, end_line: 0} as LineRange,
+    right: {start_line: 0, end_line: 0} as LineRange,
+  };
+
+  moveDetails?: {
+    changed: boolean;
+    range?: {
+      start: number;
+      end: number;
+    };
   };
 
   /**
@@ -331,22 +324,24 @@
   }
 
   _updateRange(line: GrDiffLine) {
-    if (line.beforeNumber === 'FILE' || line.afterNumber === 'FILE') {
+    if (
+      line.beforeNumber === 'FILE' ||
+      line.afterNumber === 'FILE' ||
+      line.beforeNumber === 'LOST' ||
+      line.afterNumber === 'LOST'
+    ) {
       return;
     }
 
     if (line.type === GrDiffLineType.ADD || line.type === GrDiffLineType.BOTH) {
       if (
-        this.lineRange.right.start === null ||
-        line.afterNumber < this.lineRange.right.start
+        this.lineRange.right.start_line === 0 ||
+        line.afterNumber < this.lineRange.right.start_line
       ) {
-        this.lineRange.right.start = line.afterNumber;
+        this.lineRange.right.start_line = line.afterNumber;
       }
-      if (
-        this.lineRange.right.end === null ||
-        line.afterNumber > this.lineRange.right.end
-      ) {
-        this.lineRange.right.end = line.afterNumber;
+      if (line.afterNumber > this.lineRange.right.end_line) {
+        this.lineRange.right.end_line = line.afterNumber;
       }
     }
 
@@ -355,16 +350,13 @@
       line.type === GrDiffLineType.BOTH
     ) {
       if (
-        this.lineRange.left.start === null ||
-        line.beforeNumber < this.lineRange.left.start
+        this.lineRange.left.start_line === 0 ||
+        line.beforeNumber < this.lineRange.left.start_line
       ) {
-        this.lineRange.left.start = line.beforeNumber;
+        this.lineRange.left.start_line = line.beforeNumber;
       }
-      if (
-        this.lineRange.left.end === null ||
-        line.beforeNumber > this.lineRange.left.end
-      ) {
-        this.lineRange.left.end = line.beforeNumber;
+      if (line.beforeNumber > this.lineRange.left.end_line) {
+        this.lineRange.left.end_line = line.beforeNumber;
       }
     }
   }
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 3423834..4c7d346 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
@@ -32,8 +32,8 @@
     assert.deepEqual(group.adds, [l1, l2]);
     assert.deepEqual(group.removes, [l3]);
     assert.deepEqual(group.lineRange, {
-      left: {start: 64, end: 64},
-      right: {start: 128, end: 129},
+      left: {start_line: 64, end_line: 64},
+      right: {start_line: 128, end_line: 129},
     });
 
     let pairs = group.getSideBySidePairs();
@@ -66,8 +66,8 @@
     assert.deepEqual(group.removes, []);
 
     assert.deepEqual(group.lineRange, {
-      left: {start: 64, end: 66},
-      right: {start: 128, end: 130},
+      left: {start_line: 64, end_line: 66},
+      right: {start_line: 128, end_line: 130},
     });
 
     let pairs = group.getSideBySidePairs();
@@ -122,17 +122,19 @@
           new GrDiffLine(GrDiffLineType.ADD, 0, 11),
           new GrDiffLine(GrDiffLineType.REMOVE, 10),
           new GrDiffLine(GrDiffLineType.ADD, 0, 12),
+          new GrDiffLine(GrDiffLineType.REMOVE, 11),
+          new GrDiffLine(GrDiffLineType.ADD, 0, 13),
         ]),
         new GrDiffGroup(GrDiffGroupType.BOTH, [
-          new GrDiffLine(GrDiffLineType.BOTH, 11, 13),
           new GrDiffLine(GrDiffLineType.BOTH, 12, 14),
           new GrDiffLine(GrDiffLineType.BOTH, 13, 15),
+          new GrDiffLine(GrDiffLineType.BOTH, 14, 16),
         ]),
       ];
     });
 
     test('hides hidden groups in context control', () => {
-      const collapsedGroups = hideInContextControl(groups, 3, 6);
+      const collapsedGroups = hideInContextControl(groups, 3, 7);
       assert.equal(collapsedGroups.length, 3);
 
       assert.equal(collapsedGroups[0], groups[0]);
@@ -145,7 +147,7 @@
     });
 
     test('splits partially hidden groups', () => {
-      const collapsedGroups = hideInContextControl(groups, 4, 7);
+      const collapsedGroups = hideInContextControl(groups, 4, 8);
       assert.equal(collapsedGroups.length, 4);
       assert.equal(collapsedGroups[0], groups[0]);
 
@@ -182,8 +184,8 @@
         const skipGroup = new GrDiffGroup(GrDiffGroupType.BOTH);
         skipGroup.skip = 60;
         skipGroup.lineRange = {
-          left: {start: 8, end: 67},
-          right: {start: 10, end: 69},
+          left: {start_line: 8, end_line: 67},
+          right: {start_line: 10, end_line: 69},
         };
         groups = [
           new GrDiffGroup(GrDiffGroupType.BOTH, [
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.ts
index 2d80213..2927101 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.ts
@@ -15,17 +15,17 @@
  * limitations under the License.
  */
 
+import {
+  GrDiffLine as GrDiffLineApi,
+  GrDiffLineType,
+  LineNumber,
+} from '../../../api/diff';
+
+export {GrDiffLineType, LineNumber};
+
 export const FILE = 'FILE';
-export type LineNumber = number | 'FILE';
 
-export enum GrDiffLineType {
-  ADD = 'add',
-  BOTH = 'both',
-  BLANK = 'blank',
-  REMOVE = 'remove',
-}
-
-export class GrDiffLine {
+export class GrDiffLine implements GrDiffLineApi {
   constructor(
     readonly type: GrDiffLineType,
     public beforeNumber: LineNumber = 0,
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
index 8984dc8..0ca929a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
@@ -17,6 +17,7 @@
 
 import {CommentRange} from '../../../types/common';
 import {FILE, LineNumber} from './gr-diff-line';
+import {Side} from '../../../constants/constants';
 
 /**
  * Compare two ranges. Either argument may be falsy, but will only return
@@ -38,11 +39,64 @@
   );
 }
 
+export function isLongCommentRange(range: CommentRange): boolean {
+  return range.end_line - range.start_line > 10;
+}
+
 export function getLineNumber(lineEl?: Element | null): LineNumber | null {
   if (!lineEl) return null;
   const lineNumberStr = lineEl.getAttribute('data-value');
   if (!lineNumberStr) return null;
   if (lineNumberStr === FILE) return FILE;
+  if (lineNumberStr === 'LOST') return 'LOST';
   const lineNumber = Number(lineNumberStr);
   return Number.isInteger(lineNumber) ? lineNumber : null;
 }
+
+export function getLine(threadEl: HTMLElement): LineNumber {
+  const lineAtt = threadEl.getAttribute('line-num');
+  if (lineAtt === 'LOST') return lineAtt;
+  if (!lineAtt || lineAtt === 'FILE') return FILE;
+  const line = Number(lineAtt);
+  if (isNaN(line)) throw new Error(`cannot parse line number: ${lineAtt}`);
+  if (line < 1) throw new Error(`line number smaller than 1: ${line}`);
+  return line;
+}
+
+export function getSide(threadEl: HTMLElement): Side | undefined {
+  // TODO(dhruvsri): Remove check for comment-side once all users of gr-diff
+  // start setting diff-side
+  const sideAtt =
+    threadEl.getAttribute('diff-side') || threadEl.getAttribute('comment-side');
+  if (!sideAtt) {
+    console.warn('comment thread without side');
+    return undefined;
+  }
+  if (sideAtt !== Side.LEFT && sideAtt !== Side.RIGHT)
+    throw Error(`unexpected value for side: ${sideAtt}`);
+  return sideAtt as Side;
+}
+
+export function getRange(threadEl: HTMLElement): CommentRange | undefined {
+  const rangeAtt = threadEl.getAttribute('range');
+  if (!rangeAtt) return undefined;
+  const range = JSON.parse(rangeAtt) as CommentRange;
+  if (!range.start_line) throw new Error(`invalid range: ${rangeAtt}`);
+  return range;
+}
+
+// TODO: This type should be exposed to gr-diff clients in a separate type file.
+// For Gerrit these are instances of GrCommentThread, but other gr-diff users
+// have different HTML elements in use for comment threads.
+// TODO: Also document the required HTML attributes that thread elements must
+// have, e.g. 'diff-side', 'range', 'line-num', 'data-value'.
+export interface GrDiffThreadElement extends HTMLElement {
+  rootId: string;
+}
+
+export function isThreadEl(node: Node): node is GrDiffThreadElement {
+  return (
+    node.nodeType === Node.ELEMENT_NODE &&
+    (node as Element).classList.contains('comment-thread')
+  );
+}
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 5630ce0..6974a76 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
@@ -16,32 +16,37 @@
  */
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icons/gr-icons';
 import '../gr-diff-builder/gr-diff-builder-element';
 import '../gr-diff-highlight/gr-diff-highlight';
 import '../gr-diff-selection/gr-diff-selection';
 import '../gr-syntax-themes/gr-syntax-theme';
 import '../gr-ranged-comment-themes/gr-ranged-comment-theme';
+import '../gr-ranged-comment-chip/gr-ranged-comment-chip';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {htmlTemplate} from './gr-diff_html';
-import {FILE, LineNumber} from './gr-diff-line';
-import {getLineNumber, rangesEqual} from './gr-diff-utils';
-import {getHiddenScroll} from '../../../scripts/hiddenscroll';
-import {isMergeParent, patchNumEquals} from '../../../utils/patch-set-util';
-import {customElement, observe, property} from '@polymer/decorators';
+import {LineNumber} from './gr-diff-line';
 import {
-  BlameInfo,
-  CommentRange,
+  getLine,
+  getLineNumber,
+  getRange,
+  getSide,
+  GrDiffThreadElement,
+  isLongCommentRange,
+  isThreadEl,
+  rangesEqual,
+} from './gr-diff-utils';
+import {getHiddenScroll} from '../../../scripts/hiddenscroll';
+import {customElement, observe, property} from '@polymer/decorators';
+import {BlameInfo, CommentRange, ImageInfo} from '../../../types/common';
+import {
   DiffInfo,
   DiffPreferencesInfo,
   DiffPreferencesInfoKey,
-  EditPatchSetNum,
-  ImageInfo,
-  ParentPatchSetNum,
-  PatchRange,
-} from '../../../types/common';
+} from '../../../types/diff';
 import {GrDiffHighlight} from '../gr-diff-highlight/gr-diff-highlight';
 import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
 import {
@@ -50,41 +55,33 @@
   PolymerDomWrapper,
 } from '../../../types/types';
 import {CommentRangeLayer} from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
-import {DiffViewMode, Side} from '../../../constants/constants';
+import {
+  createDefaultDiffPrefs,
+  DiffViewMode,
+  Side,
+} from '../../../constants/constants';
 import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
 import {FlattenedNodesObserver} from '@polymer/polymer/lib/utils/flattened-nodes-observer';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {AbortStop} from '../../shared/gr-cursor-manager/gr-cursor-manager';
+import {fireAlert, fireEvent} from '../../../utils/event-util';
+import {MovedLinkClickedEvent} from '../../../types/events';
 // TODO(davido): See: https://github.com/GoogleChromeLabs/shadow-selection-polyfill/issues/9
 // @ts-ignore
 import * as shadow from 'shadow-selection-polyfill/shadow.js';
 
+import {
+  CreateCommentEventDetail as CreateCommentEventDetailApi,
+  RenderPreferences,
+} from '../../../api/diff';
+import {isSafari} from '../../../utils/dom-util';
+import {assertIsDefined} from '../../../utils/common-util';
+
 const NO_NEWLINE_BASE = 'No newline at end of base file.';
 const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
 
 const LARGE_DIFF_THRESHOLD_LINES = 10000;
 const FULL_CONTEXT = -1;
-const LIMITED_CONTEXT = 10;
-
-function getSide(threadEl: GrCommentThread): Side {
-  const sideAtt = threadEl.getAttribute('comment-side');
-  if (!sideAtt) throw Error('comment thread without side');
-  if (sideAtt !== 'left' && sideAtt !== 'right')
-    throw Error(`unexpected value for side: ${sideAtt}`);
-  return sideAtt as Side;
-}
-
-function isThreadEl(node: Node): node is GrCommentThread {
-  return (
-    node.nodeType === Node.ELEMENT_NODE &&
-    (node as Element).classList.contains('comment-thread')
-  );
-}
-
-// TODO(TS): Replace by proper GrCommentThread once converted.
-type GrCommentThread = PolymerElement & {
-  rootId: string;
-  range: CommentRange;
-};
 
 const COMMIT_MSG_PATH = '/COMMIT_MSG';
 /**
@@ -111,6 +108,10 @@
   };
 }
 
+export interface CreateCommentEventDetail extends CreateCommentEventDetailApi {
+  path: string;
+}
+
 @customElement('gr-diff')
 export class GrDiff extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
@@ -158,15 +159,15 @@
   @property({type: Boolean})
   noAutoRender = false;
 
-  @property({type: Object})
-  patchRange?: PatchRange;
-
   @property({type: String, observer: '_pathObserver'})
   path?: string;
 
   @property({type: Object, observer: '_prefsObserver'})
   prefs?: DiffPreferencesInfo;
 
+  @property({type: Object, observer: '_renderPrefsChanged'})
+  renderPrefs?: RenderPreferences;
+
   @property({type: Boolean})
   displayLine = false;
 
@@ -182,6 +183,10 @@
   @property({type: Array})
   _commentRanges: CommentRangeLayer[] = [];
 
+  // explicitly highlight a range if it is not associated with any comment
+  @property({type: Object})
+  highlightRange?: CommentRange;
+
   @property({type: Array})
   coverageRanges: CoverageRange[] = [];
 
@@ -194,9 +199,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;
@@ -205,7 +219,7 @@
   diff?: DiffInfo;
 
   @property({type: Array, computed: '_computeDiffHeaderItems(diff.*)'})
-  _diffHeaderItems: unknown[] = [];
+  _diffHeaderItems: string[] = [];
 
   @property({type: String})
   _diffTableClass = '';
@@ -243,9 +257,6 @@
   @property({type: Boolean})
   showNewlineWarningRight = false;
 
-  @property({type: Boolean})
-  useNewContextControls = false;
-
   @property({
     type: String,
     computed:
@@ -278,10 +289,12 @@
   /** @override */
   created() {
     super.created();
+    this._setLoading(true);
     this.addEventListener('create-range-comment', (e: Event) =>
       this._handleCreateRangeComment(e as CustomEvent)
     );
     this.addEventListener('render-content', () => this._handleRenderContent());
+    this.addEventListener('moved-link-clicked', e => this._movedLinkClicked(e));
   }
 
   /** @override */
@@ -292,6 +305,7 @@
 
   /** @override */
   detached() {
+    this.cancelDebouncer(RENDER_DIFF_TABLE_DEBOUNCE_NAME);
     super.detached();
     this._unobserveIncrementalNodes();
     this._unobserveNodes();
@@ -361,7 +375,7 @@
     // element. This takes the shadow DOM selection if one exists.
     return this.root instanceof ShadowRoot && this.root.getSelection
       ? this.root.getSelection()
-      : this._isSafari()
+      : isSafari()
       ? shadow.getRange(this.root)
       : document.getSelection();
   }
@@ -371,24 +385,25 @@
       const addedThreadEls = info.addedNodes.filter(isThreadEl);
       const removedThreadEls = info.removedNodes.filter(isThreadEl);
       this._updateRanges(addedThreadEls, removedThreadEls);
-      this._redispatchHoverEvents(addedThreadEls);
+      addedThreadEls.forEach(threadEl =>
+        this._redispatchHoverEvents(threadEl, threadEl)
+      );
     });
   }
 
   // TODO(brohlfs): Rewrite gr-diff to be agnostic of GrCommentThread, because
   // other users of gr-diff may use different comment widgets.
   _updateRanges(
-    addedThreadEls: GrCommentThread[],
-    removedThreadEls: GrCommentThread[]
+    addedThreadEls: GrDiffThreadElement[],
+    removedThreadEls: GrDiffThreadElement[]
   ) {
     function commentRangeFromThreadEl(
-      threadEl: GrCommentThread
+      threadEl: GrDiffThreadElement
     ): CommentRangeLayer | undefined {
       const side = getSide(threadEl);
-
-      const rangeAtt = threadEl.getAttribute('range');
-      if (!rangeAtt) return undefined;
-      const range = JSON.parse(rangeAtt) as CommentRange;
+      if (!side) return undefined;
+      const range = getRange(threadEl);
+      if (!range) return undefined;
 
       return {side, range, hovering: false, rootId: threadEl.rootId};
     }
@@ -412,13 +427,20 @@
     if (addedCommentRanges && addedCommentRanges.length) {
       this.push('_commentRanges', ...addedCommentRanges);
     }
+    if (this.highlightRange) {
+      this.push('_commentRanges', {
+        side: Side.RIGHT,
+        range: this.highlightRange,
+        hovering: true,
+        rootId: '',
+      });
+    }
   }
 
   /**
    * The key locations based on the comments and line of interests,
    * where lines should not be collapsed.
    *
-   * @return
    */
   _computeKeyLocations() {
     const keyLocations: KeyLocations = {left: {}, right: {}};
@@ -432,12 +454,13 @@
 
     for (const threadEl of threadEls) {
       const side = getSide(threadEl);
-      const lineNum = Number(threadEl.getAttribute('line-num')) || FILE;
-      const commentRange = threadEl.range || {};
+      if (!side) continue;
+      const lineNum = getLine(threadEl);
+      const commentRange = getRange(threadEl);
       keyLocations[side][lineNum] = true;
       // Add start_line as well if exists,
       // the being and end of the range should not be collapsed.
-      if (commentRange.start_line) {
+      if (commentRange?.start_line) {
         keyLocations[side][commentRange.start_line] = true;
       }
     }
@@ -445,25 +468,13 @@
   }
 
   // Dispatch events that are handled by the gr-diff-highlight.
-  _redispatchHoverEvents(addedThreadEls: GrCommentThread[]) {
-    for (const threadEl of addedThreadEls) {
-      threadEl.addEventListener('mouseenter', () => {
-        threadEl.dispatchEvent(
-          new CustomEvent('comment-thread-mouseenter', {
-            bubbles: true,
-            composed: true,
-          })
-        );
-      });
-      threadEl.addEventListener('mouseleave', () => {
-        threadEl.dispatchEvent(
-          new CustomEvent('comment-thread-mouseleave', {
-            bubbles: true,
-            composed: true,
-          })
-        );
-      });
-    }
+  _redispatchHoverEvents(hoverEl: HTMLElement, threadEl: GrDiffThreadElement) {
+    hoverEl.addEventListener('mouseenter', () => {
+      fireEvent(threadEl, 'comment-thread-mouseenter');
+    });
+    hoverEl.addEventListener('mouseleave', () => {
+      fireEvent(threadEl, 'comment-thread-mouseleave');
+    });
   }
 
   /** Cancel any remaining diff builder rendering work. */
@@ -472,14 +483,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'));
   }
 
@@ -530,8 +544,9 @@
       );
       this.$.diffBuilder.showContext(e.detail.groups, e.detail.section);
     } else if (
-      el.classList.contains('lineNum') ||
-      el.classList.contains('lineNumButton')
+      el.getAttribute('data-value') !== 'LOST' &&
+      (el.classList.contains('lineNum') ||
+        el.classList.contains('lineNumButton'))
     ) {
       this.addDraftAtLine(el);
     } else if (
@@ -547,11 +562,17 @@
   }
 
   _selectLine(el: Element) {
+    const lineNumber = Number(el.getAttribute('data-value'));
+    const side = el.classList.contains('left') ? Side.LEFT : Side.RIGHT;
+    this._dispatchSelectedLine(lineNumber, side);
+  }
+
+  _dispatchSelectedLine(number: LineNumber, side: Side) {
     this.dispatchEvent(
       new CustomEvent('line-selected', {
         detail: {
-          side: el.classList.contains('left') ? Side.LEFT : Side.RIGHT,
-          number: el.getAttribute('data-value'),
+          number,
+          side,
           path: this.path,
         },
         composed: true,
@@ -560,30 +581,20 @@
     );
   }
 
+  _movedLinkClicked(e: MovedLinkClickedEvent) {
+    this._dispatchSelectedLine(e.detail.lineNum, e.detail.side);
+  }
+
   addDraftAtLine(el: Element) {
     this._selectLine(el);
-    if (!this._isValidElForComment(el)) {
-      return;
-    }
 
     const lineNum = getLineNumber(el);
     if (lineNum === null) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: 'Invalid line number'},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, 'Invalid line number');
       return;
     }
 
-    // TODO(TS): existing logic always pass undefined lineNum
-    // for file level comment, the drafts API will reject the
-    // request if file level draft contains the `line: 'FILE'` field
-    // probably should do this inside of the _createComment, this
-    // is just to keep existing behavior.
-    this._createComment(el, lineNum === FILE ? undefined : lineNum);
+    this._createComment(el, lineNum);
   }
 
   createRangeComment() {
@@ -599,7 +610,7 @@
   _createCommentForSelection(side: Side, range: CommentRange) {
     const lineNum = range.end_line;
     const lineEl = this.$.diffBuilder.getLineElByNumber(lineNum, side);
-    if (lineEl && this._isValidElForComment(lineEl)) {
+    if (lineEl) {
       this._createComment(lineEl, lineNum, side, range);
     }
   }
@@ -610,86 +621,24 @@
     this._createCommentForSelection(side, range);
   }
 
-  _isValidElForComment(el: Element) {
-    if (!this.loggedIn) {
-      this.dispatchEvent(
-        new CustomEvent('show-auth-required', {
-          composed: true,
-          bubbles: true,
-        })
-      );
-      return false;
-    }
-    if (!this.patchRange) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: 'Cannot create comment. Patch range undefined.'},
-          composed: true,
-          bubbles: true,
-        })
-      );
-      return false;
-    }
-    const patchNum = el.classList.contains(Side.LEFT)
-      ? this.patchRange.basePatchNum
-      : this.patchRange.patchNum;
-
-    const isEdit = patchNumEquals(patchNum, EditPatchSetNum);
-    const isEditBase =
-      patchNumEquals(patchNum, ParentPatchSetNum) &&
-      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,
-        })
-      );
-      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,
-        })
-      );
-      return false;
-    }
-    return true;
-  }
-
   _createComment(
     lineEl: Element,
-    lineNum?: LineNumber,
+    lineNum: LineNumber,
     side?: Side,
     range?: CommentRange
   ) {
     const contentEl = this.$.diffBuilder.getContentTdByLineEl(lineEl);
-    if (!contentEl) throw Error('content el not found for line el');
-    side = side || this._getCommentSideByLineAndContent(lineEl, contentEl);
-    const patchForNewThreads = this._getPatchNumByLineAndContent(
-      lineEl,
-      contentEl
-    );
-    const isOnParent = this._getIsParentCommentByLineAndContent(
-      lineEl,
-      contentEl
-    );
+    if (!contentEl) throw new Error('content el not found for line el');
+    side = side ?? this._getCommentSideByLineAndContent(lineEl, contentEl);
+    assertIsDefined(this.path, 'path');
     this.dispatchEvent(
-      new CustomEvent('create-comment', {
+      new CustomEvent<CreateCommentEventDetail>('create-comment', {
         bubbles: true,
         composed: true,
         detail: {
-          lineNum,
+          path: this.path,
           side,
-          patchNum: patchForNewThreads,
-          isOnParent,
+          lineNum,
           range,
         },
       })
@@ -716,52 +665,11 @@
     return threadGroupEl;
   }
 
-  /**
-   * The value to be used for the patch number of new comments created at the
-   * given line and content elements.
-   *
-   * In two cases of creating a comment on the left side, the patch number to
-   * be used should actually be right side of the patch range:
-   * - When the patch range is against the parent comment of a normal change.
-   * Such comments declare themmselves to be on the left using side=PARENT.
-   * - If the patch range is against the indexed parent of a merge change.
-   * Such comments declare themselves to be on the given parent by
-   * specifying the parent index via parent=i.
-   */
-  _getPatchNumByLineAndContent(lineEl: Element, contentEl: Element) {
-    if (!this.patchRange) throw Error('patch range not set');
-    let patchNum = this.patchRange.patchNum;
-
-    if (
-      (lineEl.classList.contains(Side.LEFT) ||
-        contentEl.classList.contains('remove')) &&
-      this.patchRange.basePatchNum !== 'PARENT' &&
-      !isMergeParent(this.patchRange.basePatchNum)
-    ) {
-      patchNum = this.patchRange.basePatchNum;
-    }
-    return patchNum;
-  }
-
-  _getIsParentCommentByLineAndContent(lineEl: Element, contentEl: Element) {
-    if (!this.patchRange) throw Error('patch range not set');
-    return (
-      (lineEl.classList.contains(Side.LEFT) ||
-        contentEl.classList.contains('remove')) &&
-      (this.patchRange.basePatchNum === 'PARENT' ||
-        isMergeParent(this.patchRange.basePatchNum))
-    );
-  }
-
   _getCommentSideByLineAndContent(lineEl: Element, contentEl: Element): Side {
-    let side = Side.RIGHT;
-    if (
-      lineEl.classList.contains(Side.LEFT) ||
+    return lineEl.classList.contains(Side.LEFT) ||
       contentEl.classList.contains('remove')
-    ) {
-      side = Side.LEFT;
-    }
-    return side;
+      ? Side.LEFT
+      : Side.RIGHT;
   }
 
   _prefsObserver(newPrefs: DiffPreferencesInfo, oldPrefs: DiffPreferencesInfo) {
@@ -841,8 +749,18 @@
     }
   }
 
+  _renderPrefsChanged(renderPrefs?: RenderPreferences) {
+    if (!renderPrefs) return;
+    if (renderPrefs.hide_left_side) {
+      this.classList.add('no-left');
+    }
+    if (renderPrefs.disable_context_control_buttons) {
+      this.updateStyles({'--context-control-display': 'none'});
+    }
+  }
+
   _diffChanged(newValue?: DiffInfo) {
-    this._loading = true;
+    this._setLoading(true);
     this._cleanup();
     if (newValue) {
       this._diffLength = this.getDiffLength(newValue);
@@ -868,9 +786,7 @@
 
   _renderDiffTable() {
     if (!this.prefs) {
-      this.dispatchEvent(
-        new CustomEvent('render', {bubbles: true, composed: true})
-      );
+      fireEvent(this, 'render');
       return;
     }
     if (
@@ -880,9 +796,7 @@
       this._safetyBypass === null
     ) {
       this._showWarning = true;
-      this.dispatchEvent(
-        new CustomEvent('render', {bubbles: true, composed: true})
-      );
+      fireEvent(this, 'render');
       return;
     }
 
@@ -902,7 +816,10 @@
   }
 
   _handleRenderContent() {
-    this._loading = false;
+    this.querySelectorAll('gr-ranged-comment-chip').forEach(element =>
+      element.remove()
+    );
+    this._setLoading(false);
     this._unobserveIncrementalNodes();
     this._incrementalNodeObserver = (dom(
       this
@@ -915,10 +832,12 @@
       // for each line from the start.
       let lastEl;
       for (const threadEl of addedThreadEls) {
-        const lineNumString = threadEl.getAttribute('line-num') || 'FILE';
+        const lineNum = getLine(threadEl);
         const commentSide = getSide(threadEl);
+        const range = getRange(threadEl);
+        if (!commentSide) continue;
         const lineEl = this.$.diffBuilder.getLineElByNumber(
-          lineNumString,
+          lineNum,
           commentSide
         );
         // When the line the comment refers to does not exist, log an error
@@ -928,17 +847,33 @@
           console.error(
             'thread attached to line ',
             commentSide,
-            lineNumString,
+            lineNum,
             ' which does not exist.'
           );
           continue;
         }
         const contentEl = this.$.diffBuilder.getContentTdByLineEl(lineEl);
         if (!contentEl) continue;
+        if (lineNum === 'LOST' && !contentEl.hasChildNodes()) {
+          contentEl.appendChild(this._portedCommentsWithoutRangeMessage());
+        }
         const threadGroupEl = this._getOrCreateThreadGroup(
           contentEl,
           commentSide
         );
+
+        const slotAtt = threadEl.getAttribute('slot');
+        if (range && isLongCommentRange(range) && slotAtt) {
+          const longRangeCommentChip = document.createElement(
+            'gr-ranged-comment-chip'
+          );
+          longRangeCommentChip.range = range;
+          longRangeCommentChip.setAttribute('threadElRootId', threadEl.rootId);
+          longRangeCommentChip.setAttribute('slot', slotAtt);
+          this.insertBefore(longRangeCommentChip, threadEl);
+          this._redispatchHoverEvents(longRangeCommentChip, threadEl);
+        }
+
         // Create a slot for the thread and attach it to the thread group.
         // The Polyfill has some bugs and this only works if the slot is
         // attached to the group after the group is attached to the DOM.
@@ -946,7 +881,6 @@
         // that is okay because the first matching slot is used and the rest
         // are ignored.
         const slot = document.createElement('slot') as HTMLSlotElement;
-        const slotAtt = threadEl.getAttribute('slot');
         if (slotAtt) slot.name = slotAtt;
         threadGroupEl.appendChild(slot);
         lastEl = threadEl;
@@ -958,9 +892,27 @@
       if (lastEl && lastEl.replaceWith) {
         lastEl.replaceWith(lastEl);
       }
+
+      const removedThreadEls = info.removedNodes.filter(isThreadEl);
+      for (const threadEl of removedThreadEls) {
+        this.querySelector(
+          `gr-ranged-comment-chip[threadElRootId="${threadEl.rootId}"]`
+        )?.remove();
+      }
     });
   }
 
+  _portedCommentsWithoutRangeMessage() {
+    const div = document.createElement('div');
+    const icon = document.createElement('iron-icon');
+    icon.setAttribute('icon', 'gr-icons:info');
+    div.appendChild(icon);
+    const span = document.createElement('span');
+    span.innerText = 'Original comment position not found in this patchset';
+    div.appendChild(span);
+    return div;
+  }
+
   _unobserveIncrementalNodes() {
     if (this._incrementalNodeObserver) {
       (dom(this) as PolymerDomWrapper).unobserveNodes(
@@ -1020,8 +972,12 @@
     this._debounceRenderDiffTable();
   }
 
-  _handleLimitedBypass() {
-    this._safetyBypass = LIMITED_CONTEXT;
+  _collapseContext() {
+    // Uses the default context amount if the preference is for the entire file.
+    this._safetyBypass =
+      this.prefs?.context && this.prefs.context >= 0
+        ? null
+        : createDefaultDiffPrefs().context;
     this._debounceRenderDiffTable();
   }
 
@@ -1033,8 +989,15 @@
     return errorMessage ? 'showError' : '';
   }
 
-  expandAllContext() {
-    this._handleFullBypass();
+  toggleAllContext() {
+    if (!this.prefs) {
+      return;
+    }
+    if (this._getBypassPrefs(this.prefs).context < 0) {
+      this._collapseContext();
+    } else {
+      this._handleFullBypass();
+    }
   }
 
   _computeNewlineWarning(warnLeft: boolean, warnRight: boolean) {
@@ -1074,13 +1037,6 @@
       }
     }, 0);
   }
-
-  _isSafari() {
-    return (
-      /^((?!chrome|android).)*safari/i.test(navigator.userAgent) ||
-      (/iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream)
-    );
-  }
 }
 
 declare global {
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 48a4596..4d0e566 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 {
@@ -56,7 +59,7 @@
     .section {
       border-right: 1px solid var(--border-color);
     }
-    .section.contextControl.newStyle {
+    .section.contextControl {
       /*
        * Divider inside this section must not have border; we set borders on
        * the padding rows below.
@@ -64,9 +67,9 @@
       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.
+     * Padding rows behind 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);
@@ -142,7 +145,7 @@
       width: 100%;
     }
     .full-width .contentText {
-      white-space: pre-wrap;
+      white-space: break-spaces;
       word-wrap: break-word;
     }
     .lineNumButton,
@@ -208,17 +211,48 @@
 
     /* dueToMove */
     .dueToMove .content.add .contentText,
-    .dueToMove .moveControls.movedIn .moveDescription,
+    .dueToMove .moveControls.movedIn .moveLabel,
     .delta.total.dueToMove .content.add .contentText {
-      background-color: var(--light-moved-add-highlight-color);
+      background-color: var(--diff-moved-in-background);
     }
+
     .dueToMove .content.remove .contentText,
-    .dueToMove .moveControls.movedOut .moveDescription,
+    .dueToMove .moveControls.movedOut .moveLabel,
     .delta.total.dueToMove .content.remove .contentText {
-      background-color: var(--light-remove-add-highlight-color);
+      background-color: var(--diff-moved-out-background);
     }
-    .moveControls {
-      text-align: right;
+
+    .delta.dueToMove .movedIn .moveDescription {
+      color: var(--diff-moved-in-background);
+      background-color: var(--diff-moved-in-label-background);
+    }
+    .delta.dueToMove .movedOut .moveDescription {
+      color: var(--diff-moved-out-background);
+      background-color: var(--diff-moved-out-label-background);
+    }
+    .moveLabel {
+      display: flex;
+      justify-content: flex-end;
+      font-family: var(--font-family, ''), 'Roboto Mono';
+      font-size: var(--font-size-small, 12px);
+    }
+    .delta.dueToMove .moveDescription {
+      border-radius: var(--fully-rounded-radius, 1000px);
+      padding: var(--spacing-s) var(--spacing-m);
+      margin: var(--spacing-s);
+      line-height: var(--line-height-small, 16px);
+      display: flex;
+    }
+
+    .moveDescription iron-icon {
+      color: inherit;
+      margin-right: var(--spacing-s);
+      height: var(--line-height-small, 16px);
+      width: var(--line-height-small, 16px);
+    }
+
+    .moveDescription a {
+      color: inherit;
     }
 
     /* ignoredWhitespaceOnly */
@@ -238,27 +272,11 @@
 
     /* 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.newStyle {
+      display: var(--context-control-display, table-row-group);
       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;
-      line-height: var(--line-height-mono, 18px);
-      --gr-button: {
-        color: var(--diff-context-control-color);
-        padding: var(--spacing-xxs) var(--spacing-l);
-      }
+      --divider-height: var(--spacing-s);
+      --divider-border: 1px;
     }
     .contextControl gr-button iron-icon {
       /* should match line-height of gr-button */
@@ -270,8 +288,8 @@
     }
 
     /*
-     * Padding rows behind new style context controls. Styled as a continuation
-     * of the line gutters and code area.
+     * Padding rows behind context controls. Styled as a continuation of the
+     * line gutters and code area.
      */
     .contextBackground > .contextLineNum {
       background-color: var(--diff-blank-background-color);
@@ -280,8 +298,8 @@
       background-color: var(--view-background-color);
     }
     .contextBackground {
-      /* 
-       * One line of background behind the context expanders which they can 
+      /*
+       * 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));
@@ -410,6 +428,13 @@
     .target-row td.blame {
       background: var(--diff-selection-background-color);
     }
+    td.lost div {
+      background-color: var(--blue-50);
+      padding: var(--spacing-s);
+    }
+    td.lost iron-icon {
+      margin-right: var(--spacing-s);
+    }
     col.blame {
       display: none;
     }
@@ -517,9 +542,9 @@
 
     /** Make comments selectable when selected */
     .selected-left.selected-comment
-      ::slotted(gr-comment-thread[comment-side='left']),
+      ::slotted(gr-comment-thread[diff-side='left']),
     .selected-right.selected-comment
-      ::slotted(gr-comment-thread[comment-side='right']) {
+      ::slotted(gr-comment-thread[diff-side='right']) {
       -webkit-user-select: text;
       -moz-user-select: text;
       -ms-user-select: text;
@@ -567,7 +592,6 @@
           base-image="[[baseImage]]"
           layers="[[layers]]"
           revision-image="[[revisionImage]]"
-          use-new-context-controls="[[useNewContextControls]]"
         >
           <table
             id="diffTable"
@@ -599,7 +623,7 @@
       Prevented render because "Whole file" is enabled and this diff is very
       large (about [[_diffLength]] lines).
     </p>
-    <gr-button on-click="_handleLimitedBypass">
+    <gr-button on-click="_collapseContext">
       Render with limited context
     </gr-button>
     <gr-button on-click="_handleFullBypass">
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..49eac72 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
@@ -16,16 +16,14 @@
  */
 
 import '../../../test/common-test-setup-karma.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
 import './gr-diff.js';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image.js';
 import {getComputedStyleValue} from '../../../utils/dom-util.js';
 import {_setHiddenScroll} from '../../../scripts/hiddenscroll.js';
 import {runA11yAudit} from '../../../test/a11y-test-utils.js';
 import '@polymer/paper-button/paper-button.js';
-import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-diff');
 
@@ -88,97 +86,10 @@
     assert.isNotOk(getComputedStyleValue('--line-limit', element));
   });
 
-  suite('_get{PatchNum|IsParentComment}ByLineAndContent', () => {
-    let lineEl;
-    let contentEl;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      lineEl = document.createElement('td');
-      contentEl = document.createElement('span');
-    });
-
-    suite('_getPatchNumByLineAndContent', () => {
-      test('right side', () => {
-        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
-        lineEl.classList.add('right');
-        assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
-            4);
-      });
-
-      test('left side parent by linenum', () => {
-        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
-        lineEl.classList.add('left');
-        assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
-            4);
-      });
-
-      test('left side parent by content', () => {
-        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
-        contentEl.classList.add('remove');
-        assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
-            4);
-      });
-
-      test('left side merge parent', () => {
-        element.patchRange = {patchNum: 4, basePatchNum: -2};
-        contentEl.classList.add('remove');
-        assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
-            4);
-      });
-
-      test('left side non parent', () => {
-        element.patchRange = {patchNum: 4, basePatchNum: 3};
-        contentEl.classList.add('remove');
-        assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
-            3);
-      });
-    });
-
-    suite('_getIsParentCommentByLineAndContent', () => {
-      test('right side', () => {
-        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
-        lineEl.classList.add('right');
-        assert.isFalse(
-            element._getIsParentCommentByLineAndContent(lineEl, contentEl));
-      });
-
-      test('left side parent by linenum', () => {
-        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
-        lineEl.classList.add('left');
-        assert.isTrue(
-            element._getIsParentCommentByLineAndContent(lineEl, contentEl));
-      });
-
-      test('left side parent by content', () => {
-        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
-        contentEl.classList.add('remove');
-        assert.isTrue(
-            element._getIsParentCommentByLineAndContent(lineEl, contentEl));
-      });
-
-      test('left side merge parent', () => {
-        element.patchRange = {patchNum: 4, basePatchNum: -2};
-        contentEl.classList.add('remove');
-        assert.isTrue(
-            element._getIsParentCommentByLineAndContent(lineEl, contentEl));
-      });
-
-      test('left side non parent', () => {
-        element.patchRange = {patchNum: 4, basePatchNum: 3};
-        contentEl.classList.add('remove');
-        assert.isFalse(
-            element._getIsParentCommentByLineAndContent(lineEl, contentEl));
-      });
-    });
-  });
-
   suite('not logged in', () => {
     setup(() => {
       const getLoggedInPromise = Promise.resolve(false);
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return getLoggedInPromise; },
-      });
+      stubRestApi('getLoggedIn').returns(getLoggedInPromise);
       element = basicFixture.instantiate();
       return getLoggedInPromise;
     });
@@ -190,14 +101,6 @@
       assert.isFalse(element.classList.contains('no-left'));
     });
 
-    test('addDraftAtLine', () => {
-      sinon.stub(element, '_selectLine');
-      const loggedInErrorSpy = sinon.spy();
-      element.addEventListener('show-auth-required', loggedInErrorSpy);
-      element.addDraftAtLine();
-      assert.isTrue(loggedInErrorSpy.called);
-    });
-
     test('view does not start with displayLine classList', () => {
       assert.isFalse(
           element.shadowRoot
@@ -258,12 +161,10 @@
         element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
         element.isImageDiff = true;
         element.prefs = {
-          auto_hide_diff_table_header: true,
           context: 10,
           cursor_blink_rate: 0,
           font_size: 12,
           ignore_whitespace: 'IGNORE_NONE',
-          intraline_difference: true,
           line_length: 100,
           line_wrapping: false,
           show_line_endings: true,
@@ -585,7 +486,7 @@
     });
 
     suite('getCursorStops', () => {
-      const setupDiff = function() {
+      function setupDiff() {
         element.diff = getMockDiffResponse();
         element.prefs = {
           context: 10,
@@ -594,19 +495,19 @@
           line_length: 100,
           cursor_blink_rate: 0,
           line_wrapping: false,
-          intraline_difference: true,
+
           show_line_endings: true,
           show_tabs: true,
           show_whitespace_errors: true,
           syntax_highlighting: true,
-          auto_hide_diff_table_header: true,
           theme: 'DEFAULT',
           ignore_whitespace: 'IGNORE_NONE',
         };
 
         element._renderDiffTable();
+        element._setLoading(false);
         flush();
-      };
+      }
 
       test('getCursorStops returns [] when hidden and noAutoRender', () => {
         element.noAutoRender = true;
@@ -654,29 +555,100 @@
           .calledWithExactly(fakeLineEl, 42));
     });
 
-    test('addDraftAtLine on an edit', () => {
-      element.patchRange.basePatchNum = SPECIAL_PATCH_SET_NUM.EDIT;
-      sinon.stub(element, '_selectLine');
-      sinon.stub(element, '_createComment');
-      const alertSpy = sinon.spy();
-      element.addEventListener('show-alert', alertSpy);
-      element.addDraftAtLine(fakeLineEl);
-      assert.isTrue(alertSpy.called);
-      assert.isFalse(element._createComment.called);
+    test('adds long range comment chip', async () => {
+      const range = {
+        start_line: 1,
+        end_line: 12,
+        start_character: 0,
+        end_character: 0,
+      };
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('diff-side', 'right');
+      threadEl.setAttribute('line-num', 1);
+      threadEl.setAttribute('range', JSON.stringify(range));
+      threadEl.setAttribute('slot', 'right-1');
+      const content = [{
+        a: [],
+        b: [],
+      }, {
+        ab: Array(13).fill('text'),
+      }];
+      setupSampleDiff({content});
+
+      element.appendChild(threadEl);
+      await flush();
+
+      assert.deepEqual(
+          element.querySelector('gr-ranged-comment-chip').range, range);
     });
 
-    test('addDraftAtLine on an edit base', () => {
-      element.patchRange.patchNum = SPECIAL_PATCH_SET_NUM.EDIT;
-      element.patchRange.basePatchNum = SPECIAL_PATCH_SET_NUM.PARENT;
-      sinon.stub(element, '_selectLine');
-      sinon.stub(element, '_createComment');
-      const alertSpy = sinon.spy();
-      element.addEventListener('show-alert', alertSpy);
-      element.addDraftAtLine(fakeLineEl);
-      assert.isTrue(alertSpy.called);
-      assert.isFalse(element._createComment.called);
+    test('no duplicate range chip for same thread', async () => {
+      const range = {
+        start_line: 1,
+        end_line: 12,
+        start_character: 0,
+        end_character: 0,
+      };
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('diff-side', 'right');
+      threadEl.setAttribute('line-num', 1);
+      threadEl.setAttribute('range', JSON.stringify(range));
+      threadEl.setAttribute('slot', 'right-1');
+      const firstChip = document.createElement('gr-ranged-comment-chip');
+      firstChip.range = range;
+      firstChip.setAttribute('threadElRootId', threadEl.rootId);
+      firstChip.setAttribute('slot', 'right-1');
+      const content = [{
+        a: [],
+        b: [],
+      }, {
+        ab: Array(13).fill('text'),
+      }];
+      setupSampleDiff({content});
+
+      element.appendChild(firstChip);
+      await flush();
+      element._handleRenderContent();
+      await flush();
+      element.appendChild(threadEl);
+      await flush();
+
+      assert.equal(
+          element.querySelectorAll('gr-ranged-comment-chip').length, 1);
     });
 
+    test('removes long range comment chip when comment is discarded',
+        async () => {
+          const range = {
+            start_line: 1,
+            end_line: 7,
+            start_character: 0,
+            end_character: 0,
+          };
+          const threadEl = document.createElement('div');
+          threadEl.className = 'comment-thread';
+          threadEl.setAttribute('diff-side', 'right');
+          threadEl.setAttribute('line-num', 1);
+          threadEl.setAttribute('range', JSON.stringify(range));
+          threadEl.setAttribute('slot', 'right-1');
+          const content = [{
+            a: [],
+            b: [],
+          }, {
+            ab: Array(8).fill('text'),
+          }];
+          setupSampleDiff({content});
+          element.appendChild(threadEl);
+          await flush();
+
+          threadEl.remove();
+          await flush();
+
+          assert.isEmpty(element.querySelectorAll('gr-ranged-comment-chip'));
+        });
+
     suite('change in preferences', () => {
       setup(() => {
         element.diff = {
@@ -822,6 +794,43 @@
       element.addEventListener('render', rendered);
       element._renderDiffTable();
     });
+
+    test('toggles expand context using bypass', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: 3};
+
+      element.toggleAllContext();
+      element._renderDiffTable();
+      await flush();
+
+      assert.equal(element.prefs.context, 3);
+      assert.equal(element._safetyBypass, -1);
+      assert.equal(renderStub.firstCall.lastArg.context, -1);
+    });
+
+    test('toggles collapse context from bypass', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: 3};
+      element._safetyBypass = -1;
+
+      element.toggleAllContext();
+      element._renderDiffTable();
+      await flush();
+
+      assert.equal(element.prefs.context, 3);
+      assert.isNull(element._safetyBypass);
+      assert.equal(renderStub.firstCall.lastArg.context, 3);
+    });
+
+    test('toggles collapse context from pref using default', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: -1};
+
+      element.toggleAllContext();
+      element._renderDiffTable();
+      await flush();
+
+      assert.equal(element.prefs.context, -1);
+      assert.equal(element._safetyBypass, 10);
+      assert.equal(renderStub.firstCall.lastArg.context, 10);
+    });
   });
 
   suite('blame', () => {
@@ -949,7 +958,7 @@
     test('line comments are key locations', () => {
       const threadEl = document.createElement('div');
       threadEl.className = 'comment-thread';
-      threadEl.setAttribute('comment-side', 'right');
+      threadEl.setAttribute('diff-side', 'right');
       threadEl.setAttribute('line-num', 3);
       element.appendChild(threadEl);
       flush();
@@ -965,7 +974,7 @@
     test('file comments are key locations', () => {
       const threadEl = document.createElement('div');
       threadEl.className = 'comment-thread';
-      threadEl.setAttribute('comment-side', 'left');
+      threadEl.setAttribute('diff-side', 'left');
       element.appendChild(threadEl);
       flush();
 
@@ -984,11 +993,10 @@
     element = basicFixture.instantiate();
     element.prefs = {
       ignore_whitespace: ignore_whitespace || 'IGNORE_ALL',
-      auto_hide_diff_table_header: true,
       context: 10,
       cursor_blink_rate: 0,
       font_size: 12,
-      intraline_difference: true,
+
       line_length: 100,
       line_wrapping: false,
       show_line_endings: 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 2a9fe54..1ce2506 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
@@ -22,7 +22,7 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-patch-range-select_html';
-import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter';
+import {pluralize} from '../../../utils/string-util';
 import {appContext} from '../../../services/app-context';
 import {
   computeLatestPatchNum,
@@ -30,9 +30,9 @@
   getParentIndex,
   getRevisionByPatchNum,
   isMergeParent,
-  patchNumEquals,
   sortRevisions,
   PatchSet,
+  convertToPatchSetNum,
 } from '../../../utils/patch-set-util';
 import {customElement, property, observe} from '@polymer/decorators';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
@@ -346,7 +346,7 @@
     patchNum: PatchSetNum,
     sortedRevisions: RevisionInfo[]
   ): boolean {
-    if (patchNumEquals(basePatchNum, ParentPatchSetNum)) {
+    if (basePatchNum === ParentPatchSetNum) {
       return false;
     }
 
@@ -367,6 +367,7 @@
     );
   }
 
+  // TODO(dhruvsri): have ported comments contribute to this count
   _computePatchSetCommentsString(
     changeComments: ChangeComments,
     patchNum: PatchSetNum
@@ -378,16 +379,11 @@
     const commentThreadCount = changeComments.computeCommentThreadCount({
       patchNum,
     });
-    const commentThreadString = GrCountStringFormatter.computePluralString(
-      commentThreadCount,
-      'comment'
-    );
+    const commentThreadString = pluralize(commentThreadCount, 'comment');
 
     const unresolvedCount = changeComments.computeUnresolvedNum({patchNum});
-    const unresolvedString = GrCountStringFormatter.computeString(
-      unresolvedCount,
-      'unresolved'
-    );
+    const unresolvedString =
+      unresolvedCount === 0 ? '' : `${unresolvedCount} unresolved`;
 
     if (!commentThreadString.length && !unresolvedString.length) {
       return '';
@@ -431,7 +427,7 @@
       basePatchNum: this.basePatchNum,
     };
     const target = (dom(e) as EventApi).localTarget;
-    const patchSetValue = e.detail.value as PatchSetNum;
+    const patchSetValue = convertToPatchSetNum(e.detail.value)!;
     const latestPatchNum = computeLatestPatchNum(this.availablePatches);
     if (target === this.$.patchNumDropdown) {
       if (detail.patchNum === e.detail.value) return;
@@ -445,7 +441,7 @@
       });
       detail.patchNum = patchSetValue;
     } else {
-      if (patchNumEquals(detail.basePatchNum, patchSetValue)) return;
+      if (detail.basePatchNum === patchSetValue) return;
       this.reporting.reportInteraction('left-patchset-changed', {
         previous: detail.basePatchNum,
         current: e.detail.value,
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 52465b3..5ab8449 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
@@ -35,7 +35,6 @@
         text-transform: none;
         font-family: var(--font-family);
       }
-      --trigger-hover-color: rgba(0, 0, 0, 0.6);
     }
     @media screen and (max-width: 50em) {
       .filesWeblinks {
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
index 15841d9..956f087 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
@@ -24,8 +24,9 @@
 import {RevisionInfo} from '../../shared/revision-info/revision-info.js';
 import {createCommentApiMockWithTemplateElement} from '../../../test/mocks/comment-api';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util.js';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api.js';
+import {stubRestApi} from '../../../test/test-utils.js';
+import {EditPatchSetNum} from '../../../types/common.js';
 
 const commentApiMockElement = createCommentApiMockWithTemplateElement(
     'gr-patch-range-select-comment-api-mock', html`
@@ -50,11 +51,9 @@
   }
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getDiffComments() { return Promise.resolve({}); },
-      getDiffRobotComments() { return Promise.resolve({}); },
-      getDiffDrafts() { return Promise.resolve({}); },
-    });
+    stubRestApi('getDiffComments').returns(Promise.resolve({}));
+    stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+    stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
 
     // Element must be wrapped in an element with direct access to the
     // comment API.
@@ -73,7 +72,7 @@
     };
     const sortedRevisions = [
       {_number: 3},
-      {_number: SPECIAL_PATCH_SET_NUM.EDIT, basePatchNum: 2},
+      {_number: EditPatchSetNum, basePatchNum: 2},
       {_number: 2},
       {_number: 1},
     ];
@@ -87,7 +86,7 @@
     }
     assert.isTrue(element._computeLeftDisabled('3', patchRange.patchNum));
 
-    patchRange.basePatchNum = SPECIAL_PATCH_SET_NUM.EDIT;
+    patchRange.basePatchNum = EditPatchSetNum;
     assert.isTrue(element._computeLeftDisabled('3', patchRange.patchNum,
         sortedRevisions));
     assert.isTrue(element._computeRightDisabled(patchRange.basePatchNum, '1',
@@ -97,7 +96,7 @@
     assert.isFalse(element._computeRightDisabled(patchRange.basePatchNum, '3',
         sortedRevisions));
     assert.isTrue(element._computeRightDisabled(patchRange.basePatchNum,
-        SPECIAL_PATCH_SET_NUM.EDIT, sortedRevisions));
+        EditPatchSetNum, sortedRevisions));
   });
 
   test('_computeBaseDropdownContent', () => {
@@ -121,7 +120,7 @@
     const patchNum = 1;
     const sortedRevisions = [
       {_number: 3, created: 'Mon, 01 Jan 2001 00:00:00 GMT'},
-      {_number: SPECIAL_PATCH_SET_NUM.EDIT, basePatchNum: 2},
+      {_number: EditPatchSetNum, basePatchNum: 2},
       {_number: 2, description: 'description'},
       {_number: 1},
     ];
@@ -285,7 +284,7 @@
     const basePatchNum = 1;
     const sortedRevisions = [
       {_number: 3, created: 'Mon, 01 Jan 2001 00:00:00 GMT'},
-      {_number: SPECIAL_PATCH_SET_NUM.EDIT, basePatchNum: 2},
+      {_number: EditPatchSetNum, basePatchNum: 2},
       {_number: 2, description: 'description'},
       {_number: 1},
     ];
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-chip/gr-ranged-comment-chip.ts b/polygerrit-ui/app/elements/diff/gr-ranged-comment-chip/gr-ranged-comment-chip.ts
new file mode 100644
index 0000000..6948283
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-chip/gr-ranged-comment-chip.ts
@@ -0,0 +1,42 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {customElement, property} from '@polymer/decorators';
+import {CommentRange} from '../../../types/common';
+import {htmlTemplate} from './gr-ranged-comment-chip_html';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+
+@customElement('gr-ranged-comment-chip')
+export class GrRangedCommentChip extends PolymerElement {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Object})
+  range?: CommentRange;
+
+  _computeRangeLabel(range?: CommentRange): string {
+    if (!range) return '';
+    return `Long comment range ${range.start_line} - ${range.end_line}`;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-ranged-comment-chip': GrRangedCommentChip;
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-chip/gr-ranged-comment-chip_html.ts b/polygerrit-ui/app/elements/diff/gr-ranged-comment-chip/gr-ranged-comment-chip_html.ts
new file mode 100644
index 0000000..c2861fa
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-chip/gr-ranged-comment-chip_html.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 {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="gr-ranged-comment-theme">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="shared-styles">
+    .row {
+      color: var(--ranged-comment-chip-text-color);
+      display: flex;
+      font-family: var(--font-family, ''), 'Roboto Mono';
+      font-size: var(--font-size-small, 12px);
+      line-height: var(--line-height-small, 16px);
+      justify-content: flex-end;
+      margin: var(--spacing-xs) 0;
+    }
+    .icon {
+      color: var(--ranged-comment-chip-text-color);
+      height: var(--line-height-small, 16px);
+      width: var(--line-height-small, 16px);
+      margin-right: var(--spacing-s);
+    }
+    .chip {
+      background-color: var(--ranged-comment-chip-background);
+      border-radius: var(--fully-rounded-radius, 1000px);
+      margin: var(--spacing-s);
+      padding: var(--spacing-s) var(--spacing-m);
+    }
+  </style>
+  <div class="row rangeHighlight">
+    <div class="chip">
+      <iron-icon class="icon" icon="gr-icons:comment-outline"></iron-icon>
+      [[_computeRangeLabel(range)]]
+    </div>
+  </div>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-chip/gr-ranged-comment-chip_test.ts b/polygerrit-ui/app/elements/diff/gr-ranged-comment-chip/gr-ranged-comment-chip_test.ts
new file mode 100644
index 0000000..8bce99d
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-chip/gr-ranged-comment-chip_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 {CommentRange} from '../../../types/common';
+import {GrRangedCommentChip} from './gr-ranged-comment-chip';
+
+suite('gr-ranged-comment-chip tests', () => {
+  let element: GrRangedCommentChip;
+
+  setup(() => {
+    element = fixtureFromElement('gr-ranged-comment-chip').instantiate();
+  });
+
+  test('shows line range', async () => {
+    element.range = {
+      start_line: 2,
+      start_character: 1,
+      end_line: 5,
+      end_character: 3,
+    } as CommentRange;
+    await flush();
+    const textDiv = element.root!.querySelector<HTMLDivElement>('.chip');
+    assert.equal(textDiv!.innerText.trim(), 'Long comment range 2 - 5');
+  });
+});
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
index bb3733f..f33c2ce 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
@@ -29,6 +29,7 @@
 } from '@polymer/polymer/interfaces';
 import {CommentRange} from '../../../types/common';
 import {DiffLayer, DiffLayerListener} from '../../../types/types';
+import {isLongCommentRange} from '../gr-diff/gr-diff-utils';
 
 /**
  * Enhanced CommentRange by UI state. Interface for incoming ranges set from the
@@ -49,6 +50,7 @@
  */
 interface CommentRangeLineLayer {
   hovering: boolean;
+  longRange: boolean;
   rootId: string;
   start: number;
   end: number;
@@ -65,8 +67,9 @@
 // Polymer 1 adds # before array's key, while Polymer 2 doesn't
 const HOVER_PATH_PATTERN = /^(commentRanges\.#?\d+)\.hovering$/;
 
-const RANGE_HIGHLIGHT = 'style-scope gr-diff range';
-const HOVER_HIGHLIGHT = 'style-scope gr-diff rangeHighlight';
+const RANGE_BASE_ONLY = 'style-scope gr-diff range';
+const RANGE_HIGHLIGHT = 'style-scope gr-diff range rangeHighlight';
+const HOVER_HIGHLIGHT = 'style-scope gr-diff range rangeHoverHighlight';
 
 @customElement('gr-ranged-comment-layer')
 export class GrRangedCommentLayer
@@ -125,8 +128,11 @@
         el,
         range.start,
         range.end - range.start,
-        (range.hovering ? HOVER_HIGHLIGHT : RANGE_HIGHLIGHT) +
-          ` ${strToClassName(range.rootId)}`
+        (range.hovering
+          ? HOVER_HIGHLIGHT
+          : range.longRange
+          ? RANGE_BASE_ONLY
+          : RANGE_HIGHLIGHT) + ` ${strToClassName(range.rootId)}`
       );
     }
   }
@@ -169,12 +175,13 @@
       const value = record.value as CommentRangeLayer[];
       this._rangesMap = {left: {}, right: {}};
       for (const {side, range, rootId, hovering} of value) {
+        const longRange = isLongCommentRange(range);
         this._updateRangesMap({
           side,
           range,
           hovering,
           operation: (forLine, start, end, hovering) => {
-            forLine.push({start, end, hovering, rootId});
+            forLine.push({start, end, hovering, rootId, longRange});
           },
         });
       }
@@ -228,12 +235,13 @@
           indexSplice.index + indexSplice.addedCount
         );
         for (const {side, range, hovering, rootId} of added) {
+          const longRange = isLongCommentRange(range);
           this._updateRangesMap({
             side,
             range,
             hovering,
             operation: (forLine, start, end, hovering) => {
-              forLine.push({start, end, hovering, rootId});
+              forLine.push({start, end, hovering, rootId, longRange});
             },
           });
         }
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.js b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.js
index 441d585..8279ab1 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.js
@@ -68,6 +68,15 @@
         },
         rootId: 'd',
       },
+      {
+        side: 'right',
+        range: {
+          end_character: 1,
+          end_line: 71,
+          start_character: 1,
+          start_line: 60,
+        },
+      },
     ];
 
     element = basicFixture.instantiate();
@@ -110,7 +119,10 @@
       assert.equal(lastCall.args[0], el);
       assert.equal(lastCall.args[1], expectedStart);
       assert.equal(lastCall.args[2], expectedLength);
-      assert.equal(lastCall.args[3], 'style-scope gr-diff range generated_a');
+      assert.equal(
+          lastCall.args[3],
+          'style-scope gr-diff range rangeHighlight generated_a'
+      );
     });
 
     test('type=Remove has-comment hovering', () => {
@@ -129,7 +141,8 @@
       assert.equal(lastCall.args[1], expectedStart);
       assert.equal(lastCall.args[2], expectedLength);
       assert.equal(
-          lastCall.args[3], 'style-scope gr-diff rangeHighlight generated_a'
+          lastCall.args[3],
+          'style-scope gr-diff range rangeHoverHighlight generated_a'
       );
     });
 
@@ -147,7 +160,10 @@
       assert.equal(lastCall.args[0], el);
       assert.equal(lastCall.args[1], expectedStart);
       assert.equal(lastCall.args[2], expectedLength);
-      assert.equal(lastCall.args[3], 'style-scope gr-diff range generated_a');
+      assert.equal(
+          lastCall.args[3],
+          'style-scope gr-diff range rangeHighlight generated_a'
+      );
     });
 
     test('type=Both has-comment off side', () => {
@@ -175,7 +191,24 @@
       assert.equal(lastCall.args[0], el);
       assert.equal(lastCall.args[1], expectedStart);
       assert.equal(lastCall.args[2], expectedLength);
-      assert.equal(lastCall.args[3], 'style-scope gr-diff range generated_b');
+      assert.equal(
+          lastCall.args[3],
+          'style-scope gr-diff range rangeHighlight generated_b'
+      );
+    });
+
+    test('long range comment', () => {
+      line.type = GrDiffLineType.ADD;
+      line.afterNumber = 65;
+      el.setAttribute('data-side', 'right');
+
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(
+          annotateElementStub.lastCall.args[3],
+          'style-scope gr-diff range generated_'
+      );
     });
   });
 
@@ -281,10 +314,10 @@
     assert.equal(element._rangesMap.left[39][0].start, 0);
     assert.equal(element._rangesMap.left[39][0].end, 9);
 
-    // The right has two ranged comments, one spanning ll.10-12 and the other
-    // on line 100.
+    // The right has four ranged comments: 10-12, 55-55, 60-71, 100-100
     const rightKeys = [];
     for (let i = 10; i <= 12; i++) { rightKeys.push('' + i); }
+    for (let i = 60; i <= 71; i++) { rightKeys.push('' + i); }
     rightKeys.push('55', '100');
     assert.deepEqual(Object.keys(element._rangesMap.right).sort(),
         rightKeys.sort());
@@ -318,4 +351,3 @@
     assert.equal(range.end, line.text.length);
   });
 });
-
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts b/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts
index 70ee196..ee9e8f9 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts
@@ -25,11 +25,11 @@
 $_documentContainer.innerHTML = `<dom-module id="gr-ranged-comment-theme">
   <template>
     <style>
-      .range {
+      .rangeHighlight {
         background-color: var(--diff-highlight-range-color);
         display: inline;
       }
-      .rangeHighlight {
+      .rangeHoverHighlight {
         background-color: var(--diff-highlight-range-hover-color);
         display: inline;
       }
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts
index d702fb1..ee52ab6 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts
@@ -22,6 +22,7 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-selection-action-box_html';
+import {fireEvent} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -50,7 +51,7 @@
    */
 
   @property({type: Object})
-  keyEventTarget: Record<string, any> = document.body;
+  keyEventTarget = document.body;
 
   @property({type: Boolean})
   positionBelow = false;
@@ -122,11 +123,6 @@
     } // 0 = main button
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('create-comment-requested', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'create-comment-requested');
   }
 }
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..1150674 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
@@ -14,17 +14,16 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../shared/gr-lib-loader/gr-lib-loader';
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-syntax-layer_html';
 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';
 
@@ -159,18 +158,12 @@
   lastNotify: {left: number; right: number};
 }
 
-export interface GrSyntaxLayer {
-  $: {
-    libLoader: GrLibLoader;
-  };
-}
-
 @customElement('gr-syntax-layer')
 export class GrSyntaxLayer
   extends GestureEventListeners(LegacyElementMixin(PolymerElement))
   implements DiffLayer {
   static get template() {
-    return htmlTemplate;
+    return html``;
   }
 
   @property({type: Object, observer: '_diffChanged'})
@@ -203,6 +196,8 @@
   @property({type: Object})
   _hljs?: HighlightJS;
 
+  private readonly libLoader = new GrLibLoader();
+
   addListener(listener: DiffLayerListener) {
     this.push('_listeners', listener);
   }
@@ -217,9 +212,8 @@
    */
   annotate(el: HTMLElement, _: HTMLElement, line: GrDiffLine) {
     if (!this.enabled) return;
-    if (line.beforeNumber === FILE) return;
-    if (line.afterNumber === FILE) return;
-
+    if (line.beforeNumber === FILE || line.afterNumber === FILE) return;
+    if (line.beforeNumber === 'LOST' || line.afterNumber === 'LOST') return;
     // Determine the side.
     let side;
     if (
@@ -299,12 +293,12 @@
       lastNotify: {left: 1, right: 1},
     };
 
-    const rangesCache = new Map();
+    const rangesCache = new Map<string, SyntaxLayerRange[]>();
 
     this._processPromise = util.makeCancelable(
       this._loadHLJS().then(
         () =>
-          new Promise(resolve => {
+          new Promise<void>(resolve => {
             const nextStep = () => {
               this._processHandle = null;
               this._processNextLine(state, rangesCache);
@@ -498,7 +492,7 @@
    * with code it shouldn't AND to avoid executing regexes as much as
    * possible.
    * * These tests should document the issue clearly enough that the test can
-   * be condidently removed when the issue is solved in HLJS.
+   * be confidently removed when the issue is solved in HLJS.
    * * These tests should rewrite the line of code to have the same number of
    * characters. This method rewrites the string that gets parsed, but NOT
    * the string that gets displayed and highlighted. Thus, the positions
@@ -598,7 +592,7 @@
   }
 
   _loadHLJS() {
-    return this.$.libLoader.getHLJS().then(hljs => {
+    return this.libLoader.getHLJS().then(hljs => {
       this._hljs = hljs;
     });
   }
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.js b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.js
index 6a2bbca..20106d8 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.js
@@ -173,7 +173,7 @@
 
     const mockHLJS = getMockHLJS();
     const highlightSpy = sinon.spy(mockHLJS, 'highlight');
-    sinon.stub(element.$.libLoader, 'getHLJS').callsFake(
+    sinon.stub(element.libLoader, 'getHLJS').callsFake(
         () => Promise.resolve(mockHLJS));
     const processNextSpy = sinon.spy(element, '_processNextLine');
     const processPromise = element.process();
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
index 5967b03..7df3d75 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
@@ -17,7 +17,6 @@
 import '../../../styles/gr-table-styles';
 import '../../../styles/shared-styles';
 import '../../shared/gr-list-view/gr-list-view';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
@@ -28,14 +27,10 @@
 } from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
 import {getBaseUrl} from '../../../utils/url-util';
 import {customElement, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {DocResult} from '../../../types/common';
+import {fireTitleChange} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
 
-export interface GrDocumentationSearch {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
 @customElement('gr-documentation-search')
 export class GrDocumentationSearch extends ListViewMixin(
   GestureEventListeners(LegacyElementMixin(PolymerElement))
@@ -59,12 +54,12 @@
   @property({type: String})
   _filter = '';
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
   attached() {
     super.attached();
-    this.dispatchEvent(
-      new CustomEvent('title-change', {detail: {title: 'Documentation Search'}})
-    );
+    fireTitleChange(this, 'Documentation Search');
   }
 
   _paramsChanged(params: ListViewParams) {
@@ -76,14 +71,16 @@
 
   _getDocumentationSearches(filter: string) {
     this._documentationSearches = [];
-    return this.$.restAPI.getDocumentationSearches(filter).then(searches => {
-      // Late response.
-      if (filter !== this._filter || !searches) {
-        return;
-      }
-      this._documentationSearches = searches;
-      this._loading = false;
-    });
+    return this.restApiService
+      .getDocumentationSearches(filter)
+      .then(searches => {
+        // Late response.
+        if (filter !== this._filter || !searches) {
+          return;
+        }
+        this._documentationSearches = searches;
+        this._loading = false;
+      });
   }
 
   _computeSearchUrl(url?: string) {
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts
index 9f5aae3..dd1faeb 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts
@@ -54,5 +54,4 @@
       </tbody>
     </table>
   </gr-list-view>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.js b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.js
index ce80f2f..f5e47ca 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.js
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.js
@@ -19,6 +19,7 @@
 import './gr-documentation-search.js';
 import {page} from '../../../utils/page-wrapper-utils.js';
 import 'lodash/lodash.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-documentation-search');
 
@@ -45,11 +46,8 @@
   suite('list with searches for documentation', () => {
     setup(done => {
       documentationSearches = _.times(26, documentationGenerator);
-      stub('gr-rest-api-interface', {
-        getDocumentationSearches() {
-          return Promise.resolve(documentationSearches);
-        },
-      });
+      stubRestApi('getDocumentationSearches').returns(
+          Promise.resolve(documentationSearches));
       element._paramsChanged(value).then(() => { flush(done); });
     });
 
@@ -70,19 +68,12 @@
       _.times(1, documentationSearches);
     });
 
-    test('_paramsChanged', done => {
-      sinon.stub(
-          element.$.restAPI,
-          'getDocumentationSearches')
-          .callsFake(() => Promise.resolve(documentationSearches));
-      const value = {
-        filter: 'test',
-      };
-      element._paramsChanged(value).then(() => {
-        assert.isTrue(element.$.restAPI.getDocumentationSearches.lastCall
-            .calledWithExactly('test'));
-        done();
-      });
+    test('_paramsChanged', async () => {
+      const stub = stubRestApi('getDocumentationSearches').returns(
+          Promise.resolve(documentationSearches));
+      const value = {filter: 'test'};
+      await element._paramsChanged(value);
+      assert.isTrue(stub.lastCall.calledWithExactly('test'));
     });
   });
 
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-edit-controls/gr-edit-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
index cf011bb..bc153ee 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
@@ -20,7 +20,6 @@
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-dropdown/gr-dropdown';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/shared-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
@@ -33,15 +32,14 @@
 import {ChangeInfo, PatchSetNum} from '../../../types/common';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {
   AutocompleteQuery,
   AutocompleteSuggestion,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
+import {appContext} from '../../../services/app-context';
 
 export interface GrEditControls {
   $: {
-    restAPI: RestApiService & Element;
     overlay: GrOverlay;
     openDialog: GrDialog;
     deleteDialog: GrDialog;
@@ -79,6 +77,8 @@
   @property({type: Object})
   _query: AutocompleteQuery;
 
+  private readonly restApiService = appContext.restApiService;
+
   constructor() {
     super();
     this._query = (input: string) => this._queryFiles(input);
@@ -221,7 +221,7 @@
       this._closeDialog(this.$.openDialog, true);
       return;
     }
-    return this.$.restAPI
+    return this.restApiService
       .saveFileUploadChangeEdit(this.change._number, path, fileData)
       .then(res => {
         if (!res || !res.ok) {
@@ -236,7 +236,7 @@
     // Get the dialog before the api call as the event will change during bubbling
     // which will make Polymer.dom(e).path an empty array in polymer 2
     const dialog = this._getDialogFromEvent(e);
-    this.$.restAPI
+    this.restApiService
       .deleteFileInChangeEdit(this.change._number, this._path)
       .then(res => {
         if (!res || !res.ok) {
@@ -249,7 +249,7 @@
 
   _handleRestoreConfirm(e: Event) {
     const dialog = this._getDialogFromEvent(e);
-    this.$.restAPI
+    this.restApiService
       .restoreFileInChangeEdit(this.change._number, this._path)
       .then(res => {
         if (!res || !res.ok) {
@@ -262,7 +262,7 @@
 
   _handleRenameConfirm(e: Event) {
     const dialog = this._getDialogFromEvent(e);
-    return this.$.restAPI
+    return this.restApiService
       .renameFileInChangeEdit(this.change._number, this._path, this._newPath)
       .then(res => {
         if (!res || !res.ok) {
@@ -274,10 +274,11 @@
   }
 
   _queryFiles(input: string): Promise<AutocompleteSuggestion[]> {
-    return this.$.restAPI
+    return this.restApiService
       .queryChangeFiles(this.change._number, this.patchNum, input)
       .then(res => {
-        if (!res) throw new Error('Failed to retrieve files. Reponse not set.');
+        if (!res)
+          throw new Error('Failed to retrieve files. Response not set.');
         return res.map(file => {
           return {name: file};
         });
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_html.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_html.ts
index b67f6cd..eac796e 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_html.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_html.ts
@@ -179,5 +179,4 @@
       </div>
     </gr-dialog>
   </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.js b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.js
index 98d1545..bbf4790 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.js
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.js
@@ -19,7 +19,7 @@
 import './gr-edit-controls.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {createIronOverlayBackdropStyleEl} from '../../../test/test-utils.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-edit-controls');
 
@@ -29,24 +29,17 @@
   let showDialogSpy;
   let closeDialogSpy;
   let queryStub;
-  let ironOverlayBackdropStyleEl;
 
   setup(() => {
-    ironOverlayBackdropStyleEl = createIronOverlayBackdropStyleEl();
     element = basicFixture.instantiate();
     element.change = {_number: '42'};
     showDialogSpy = sinon.spy(element, '_showDialog');
     closeDialogSpy = sinon.spy(element, '_closeDialog');
     sinon.stub(element, '_hideAllDialogs');
-    queryStub = sinon.stub(element.$.restAPI, 'queryChangeFiles')
-        .returns(Promise.resolve([]));
+    queryStub = stubRestApi('queryChangeFiles').returns(Promise.resolve([]));
     flush();
   });
 
-  teardown(() => {
-    ironOverlayBackdropStyleEl.remove();
-  });
-
   test('all actions exist', () => {
     // We take 1 away from the total found, due to an extra button being
     // added for the file uploads (browse).
@@ -57,14 +50,14 @@
 
   suite('edit button CUJ', () => {
     let navStubs;
-    let openAutoCcmplete;
+    let openAutoComplete;
 
     setup(() => {
       navStubs = [
         sinon.stub(GerritNav, 'getEditUrlForDiff'),
         sinon.stub(GerritNav, 'navigateToRelativeUrl'),
       ];
-      openAutoCcmplete = element.$.openDialog.querySelector('gr-autocomplete');
+      openAutoComplete = element.$.openDialog.querySelector('gr-autocomplete');
     });
 
     test('_isValidPath', () => {
@@ -84,9 +77,9 @@
         assert.isFalse(queryStub.called);
         // Setup _focused manually - in headless mode Chrome sometimes don't
         // setup focus. flush and/or flushAsynchronousOperations don't help
-        openAutoCcmplete._focused = true;
-        openAutoCcmplete.noDebounce = true;
-        openAutoCcmplete.text = 'src/test.cpp';
+        openAutoComplete._focused = true;
+        openAutoComplete.noDebounce = true;
+        openAutoComplete.text = 'src/test.cpp';
         assert.isTrue(queryStub.called);
         assert.isFalse(element.$.openDialog.disabled);
         MockInteractions.tap(element.$.openDialog.shadowRoot
@@ -102,8 +95,8 @@
       MockInteractions.tap(element.shadowRoot.querySelector('#open'));
       return showDialogSpy.lastCall.returnValue.then(() => {
         assert.isTrue(element.$.openDialog.disabled);
-        openAutoCcmplete.noDebounce = true;
-        openAutoCcmplete.text = 'src/test.cpp';
+        openAutoComplete.noDebounce = true;
+        openAutoComplete.text = 'src/test.cpp';
         assert.isFalse(element.$.openDialog.disabled);
         MockInteractions.tap(element.$.openDialog.shadowRoot
             .querySelector('gr-button'));
@@ -121,7 +114,7 @@
 
     setup(() => {
       navStub = sinon.stub(GerritNav, 'navigateToChange');
-      deleteStub = sinon.stub(element.$.restAPI, 'deleteFileInChangeEdit');
+      deleteStub = stubRestApi('deleteFileInChangeEdit');
       deleteAutocomplete =
           element.$.deleteDialog.querySelector('gr-autocomplete');
     });
@@ -205,7 +198,7 @@
 
     setup(() => {
       navStub = sinon.stub(GerritNav, 'navigateToChange');
-      renameStub = sinon.stub(element.$.restAPI, 'renameFileInChangeEdit');
+      renameStub = stubRestApi('renameFileInChangeEdit');
       renameAutocomplete =
           element.$.renameDialog.querySelector('gr-autocomplete');
     });
@@ -298,7 +291,8 @@
 
     setup(() => {
       navStub = sinon.stub(GerritNav, 'navigateToChange');
-      restoreStub = sinon.stub(element.$.restAPI, 'restoreFileInChangeEdit');
+      restoreStub = stubRestApi(
+          'restoreFileInChangeEdit');
     });
 
     test('restore hidden by default', () => {
@@ -362,7 +356,7 @@
 
     setup(() => {
       navStub = sinon.stub(GerritNav, 'navigateToChange');
-      fileStub = sinon.stub(element.$.restAPI, 'saveFileUploadChangeEdit');
+      fileStub = stubRestApi('saveFileUploadChangeEdit');
     });
 
     test('_handleUploadConfirm', () => {
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 a0562de..1e08a5c 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
@@ -18,7 +18,6 @@
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-editable-label/gr-editable-label';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-storage/gr-storage';
 import '../gr-default-editor/gr-default-editor';
 import '../../../styles/shared-styles';
@@ -31,22 +30,22 @@
   GerritNav,
   GenerateUrlEditViewParameters,
 } from '../../core/gr-navigation/gr-navigation';
-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,
-  ErrorCallback,
-} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {
   ChangeInfo,
   PatchSetNum,
   EditPreferencesInfo,
   Base64FileContent,
   NumericChangeId,
+  EditPatchSetNum,
 } from '../../../types/common';
 import {GrStorage} from '../../shared/gr-storage/gr-storage';
 import {HttpMethod, NotifyType} from '../../../constants/constants';
+import {fireAlert, fireTitleChange} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
+import {assertIsDefined} from '../../../utils/common-util';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
 const SAVING_MESSAGE = 'Saving changes...';
@@ -57,12 +56,8 @@
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 100;
 
-export interface GrEditorView {
-  $: {
-    restAPI: RestApiService & Element;
-    storage: GrStorage;
-  };
-}
+const DEBOUNCER_STORE = 'store';
+
 @customElement('gr-editor-view')
 export class GrEditorView extends KeyboardShortcutMixin(
   GestureEventListeners(LegacyElementMixin(PolymerElement))
@@ -125,6 +120,12 @@
   @property({type: Number})
   _lineNum?: number;
 
+  private readonly restApiService = appContext.restApiService;
+
+  private readonly storage = new GrStorage();
+
+  reporting = appContext.reportingService;
+
   get keyBindings() {
     return {
       'ctrl+s meta+s': '_handleSaveShortcut',
@@ -147,16 +148,21 @@
     });
   }
 
+  /** @override */
+  detached() {
+    this.cancelDebouncer(DEBOUNCER_STORE);
+  }
+
   get storageKey() {
     return `c${this._changeNum}_ps${this._patchNum}_${this._path}`;
   }
 
   _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
+    return this.restApiService.getLoggedIn();
   }
 
   _getEditPrefs() {
-    return this.$.restAPI.getEditPreferences();
+    return this.restApiService.getEditPreferences();
   }
 
   _paramsChanged(value: GenerateUrlEditViewParameters) {
@@ -166,8 +172,7 @@
 
     this._changeNum = value.changeNum;
     this._path = value.path;
-    this._patchNum =
-      value.patchNum || (SPECIAL_PATCH_SET_NUM.EDIT as PatchSetNum);
+    this._patchNum = value.patchNum || (EditPatchSetNum as PatchSetNum);
     this._lineNum =
       typeof value.lineNum === 'string' ? Number(value.lineNum) : value.lineNum;
 
@@ -176,13 +181,7 @@
     // has been queued, the event can bubble up to the handler in gr-app.
     this.async(() => {
       const title = `Editing ${computeTruncatedPath(value.path)}`;
-      this.dispatchEvent(
-        new CustomEvent('title-change', {
-          detail: {title},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireTitleChange(this, title);
     });
 
     const promises = [];
@@ -195,13 +194,13 @@
   }
 
   _getChangeDetail(changeNum: NumericChangeId) {
-    return this.$.restAPI.getDiffChangeDetail(changeNum).then(change => {
+    return this.restApiService.getDiffChangeDetail(changeNum).then(change => {
       this._change = change;
     });
   }
 
   _handlePathChanged(e: CustomEvent<string>) {
-    // TODO(TS) could be cleand up, it was added for type requirements
+    // TODO(TS) could be cleaned up, it was added for type requirements
     if (this._changeNum === undefined || !this._path) {
       return Promise.reject(new Error('changeNum or path undefined'));
     }
@@ -209,7 +208,7 @@
     if (path === this._path) {
       return Promise.resolve();
     }
-    return this.$.restAPI
+    return this.restApiService
       .renameFileInChangeEdit(this._changeNum, this._path, path)
       .then(res => {
         if (!res || !res.ok) {
@@ -223,14 +222,14 @@
 
   _viewEditInChangeView() {
     const patch = this._successfulSave
-      ? (SPECIAL_PATCH_SET_NUM.EDIT as PatchSetNum)
+      ? (EditPatchSetNum as PatchSetNum)
       : this._patchNum;
     if (this._change && patch)
       GerritNav.navigateToChange(
         this._change,
         patch,
         undefined,
-        patch !== SPECIAL_PATCH_SET_NUM.EDIT
+        patch !== EditPatchSetNum
       );
   }
 
@@ -242,11 +241,9 @@
     if (patchNum === undefined) {
       return Promise.reject(new Error('patchNum undefined'));
     }
-    const storedContent = this.$.storage.getEditableContentItem(
-      this.storageKey
-    );
+    const storedContent = this.storage.getEditableContentItem(this.storageKey);
 
-    return this.$.restAPI
+    return this.restApiService
       .getFileContent(changeNum, path, patchNum)
       .then(res => {
         const content = (res && (res as Base64FileContent).content) || '';
@@ -255,13 +252,7 @@
           storedContent.message &&
           storedContent.message !== content
         ) {
-          this.dispatchEvent(
-            new CustomEvent('show-alert', {
-              detail: {message: RESTORED_MESSAGE},
-              bubbles: true,
-              composed: true,
-            })
-          );
+          fireAlert(this, RESTORED_MESSAGE);
 
           this._newContent = storedContent.message;
         } else {
@@ -286,10 +277,10 @@
     }
     this._saving = true;
     this._showAlert(SAVING_MESSAGE);
-    this.$.storage.eraseEditableContentItem(this.storageKey);
+    this.storage.eraseEditableContentItem(this.storageKey);
     if (!this._newContent)
       return Promise.reject(new Error('new content undefined'));
-    return this.$.restAPI
+    return this.restApiService
       .saveChangeEdit(this._changeNum, this._path, this._newContent)
       .then(res => {
         this._saving = false;
@@ -305,13 +296,7 @@
   }
 
   _showAlert(message: string) {
-    this.dispatchEvent(
-      new CustomEvent('show-alert', {
-        detail: {message},
-        bubbles: true,
-        composed: true,
-      })
-    );
+    fireAlert(this, message);
   }
 
   _computeSaveDisabled(
@@ -342,18 +327,18 @@
   }
 
   _handlePublishTap() {
-    if (!this._changeNum) throw new Error('missing changeNum');
+    assertIsDefined(this._changeNum, '_changeNum');
 
     const changeNum = this._changeNum;
     this._saveEdit().then(() => {
       const handleError: ErrorCallback = response => {
         this._showAlert(PUBLISH_FAILED_MSG);
-        console.error(response);
+        this.reporting.error(new Error(response?.statusText));
       };
 
       this._showAlert(PUBLISHING_EDIT_MSG);
 
-      this.$.restAPI
+      this.restApiService
         .executeChangeAction(
           changeNum,
           HttpMethod.POST,
@@ -363,7 +348,7 @@
           handleError
         )
         .then(() => {
-          if (!this._change) throw new Error('missing change');
+          assertIsDefined(this._change, '_change');
           GerritNav.navigateToChange(this._change);
         });
     });
@@ -371,14 +356,14 @@
 
   _handleContentChange(e: CustomEvent<{value: string}>) {
     this.debounce(
-      'store',
+      DEBOUNCER_STORE,
       () => {
         const content = e.detail.value;
         if (content) {
           this.set('_newContent', e.detail.value);
-          this.$.storage.setEditableContentItem(this.storageKey, content);
+          this.storage.setEditableContentItem(this.storageKey, content);
         } else {
-          this.$.storage.eraseEditableContentItem(this.storageKey);
+          this.storage.eraseEditableContentItem(this.storageKey);
         }
       },
       STORAGE_DEBOUNCE_INTERVAL_MS
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 1e05232..0df04cb 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
@@ -133,6 +133,4 @@
       ></gr-default-editor>
     </gr-endpoint-decorator>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  <gr-storage id="storage"></gr-storage>
 `;
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 b04277e..8512545 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
@@ -18,8 +18,9 @@
 import '../../../test/common-test-setup-karma.js';
 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';
+import {stubRestApi} from '../../../test/test-utils.js';
+import {EditPatchSetNum} from '../../../types/common.js';
 
 const basicFixture = fixtureFromElement('gr-editor-view');
 
@@ -37,15 +38,11 @@
   };
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(true); },
-      getEditPreferences() { return Promise.resolve({}); },
-    });
-
     element = basicFixture.instantiate();
-    savePathStub = sinon.stub(element.$.restAPI, 'renameFileInChangeEdit');
-    saveFileStub = sinon.stub(element.$.restAPI, 'saveChangeEdit');
-    changeDetailStub = sinon.stub(element.$.restAPI, 'getDiffChangeDetail');
+    savePathStub = stubRestApi('renameFileInChangeEdit');
+    saveFileStub = stubRestApi('saveChangeEdit');
+    changeDetailStub = stubRestApi(
+        'getDiffChangeDetail');
     navigateStub = sinon.stub(element, '_viewEditInChangeView');
   });
 
@@ -106,7 +103,7 @@
   });
 
   test('reacts to content-change event', () => {
-    const storeStub = sinon.spy(element.$.storage, 'setEditableContentItem');
+    const storeStub = sinon.spy(element.storage, 'setEditableContentItem');
     element._newContent = 'test';
     element.$.editorEndpoint.dispatchEvent(new CustomEvent('content-change', {
       bubbles: true, composed: true,
@@ -139,7 +136,7 @@
 
     test('file modification and save, !ok response', () => {
       const saveSpy = sinon.spy(element, '_saveEdit');
-      const eraseStub = sinon.stub(element.$.storage,
+      const eraseStub = sinon.stub(element.storage,
           'eraseEditableContentItem');
       const alertStub = sinon.stub(element, '_showAlert');
       saveFileStub.returns(Promise.resolve({ok: false}));
@@ -199,7 +196,7 @@
       const saveSpy = sinon.spy(element, '_saveEdit');
       const alertStub = sinon.stub(element, '_showAlert');
       const changeActionsStub =
-        sinon.stub(element.$.restAPI, 'executeChangeAction');
+        stubRestApi('executeChangeAction');
       saveFileStub.returns(Promise.resolve({ok: true}));
       element._newContent = newText;
       flush();
@@ -251,11 +248,11 @@
       element._newContent = 'initial';
       element._content = 'initial';
       element._type = 'initial';
-      sinon.stub(element.$.storage, 'getEditableContentItem').returns(null);
+      sinon.stub(element.storage, 'getEditableContentItem').returns(null);
     });
 
     test('res.ok', () => {
-      sinon.stub(element.$.restAPI, 'getFileContent')
+      stubRestApi('getFileContent')
           .returns(Promise.resolve({
             ok: true,
             type: 'text/javascript',
@@ -271,7 +268,7 @@
     });
 
     test('!res.ok', () => {
-      sinon.stub(element.$.restAPI, 'getFileContent')
+      stubRestApi('getFileContent')
           .returns(Promise.resolve({}));
 
       // Ensure no data is set with a bad response.
@@ -283,7 +280,7 @@
     });
 
     test('content is undefined', () => {
-      sinon.stub(element.$.restAPI, 'getFileContent')
+      stubRestApi('getFileContent')
           .returns(Promise.resolve({
             ok: true,
             type: 'text/javascript',
@@ -297,7 +294,7 @@
     });
 
     test('content and type is undefined', () => {
-      sinon.stub(element.$.restAPI, 'getFileContent')
+      stubRestApi('getFileContent')
           .returns(Promise.resolve({
             ok: true,
           }));
@@ -324,15 +321,15 @@
     element._change = {};
     navigateStub.restore();
     const navStub = sinon.stub(GerritNav, 'navigateToChange');
-    element._patchNum = SPECIAL_PATCH_SET_NUM.EDIT;
+    element._patchNum = EditPatchSetNum;
     element._viewEditInChangeView();
-    assert.equal(navStub.lastCall.args[1], SPECIAL_PATCH_SET_NUM.EDIT);
+    assert.equal(navStub.lastCall.args[1], EditPatchSetNum);
     element._patchNum = '1';
     element._viewEditInChangeView();
     assert.equal(navStub.lastCall.args[1], '1');
     element._successfulSave = true;
     element._viewEditInChangeView();
-    assert.equal(navStub.lastCall.args[1], SPECIAL_PATCH_SET_NUM.EDIT);
+    assert.equal(navStub.lastCall.args[1], EditPatchSetNum);
   });
 
   suite('keyboard shortcuts', () => {
@@ -380,9 +377,9 @@
 
   suite('gr-storage caching', () => {
     test('local edit exists', () => {
-      sinon.stub(element.$.storage, 'getEditableContentItem')
+      sinon.stub(element.storage, 'getEditableContentItem')
           .returns({message: 'pending edit'});
-      sinon.stub(element.$.restAPI, 'getFileContent')
+      stubRestApi('getFileContent')
           .returns(Promise.resolve({
             ok: true,
             type: 'text/javascript',
@@ -403,9 +400,9 @@
     });
 
     test('local edit exists, is same as remote edit', () => {
-      sinon.stub(element.$.storage, 'getEditableContentItem')
+      sinon.stub(element.storage, 'getEditableContentItem')
           .returns({message: 'pending edit'});
-      sinon.stub(element.$.restAPI, 'getFileContent')
+      stubRestApi('getFileContent')
           .returns(Promise.resolve({
             ok: true,
             type: 'text/javascript',
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index c2fb124..ec3bd0f 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -37,8 +37,6 @@
 import './settings/gr-cla-view/gr-cla-view';
 import './settings/gr-registration-dialog/gr-registration-dialog';
 import './settings/gr-settings-view/gr-settings-view';
-import './shared/gr-lib-loader/gr-lib-loader';
-import './shared/gr-rest-api-interface/gr-rest-api-interface';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
@@ -49,11 +47,10 @@
   Shortcut,
   SPECIAL_SHORTCUT,
 } from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
-import {GerritNav, GerritView} from './core/gr-navigation/gr-navigation';
+import {GerritNav} from './core/gr-navigation/gr-navigation';
 import {appContext} from '../services/app-context';
 import {flush} from '@polymer/polymer/lib/utils/flush';
 import {customElement, observe, property} from '@polymer/decorators';
-import {RestApiService} from '../services/services/gr-rest-api/gr-rest-api';
 import {GrRouter} from './core/gr-router/gr-router';
 import {
   AccountDetailInfo,
@@ -77,8 +74,12 @@
   RpcLogEvent,
   ShortcutTriggeredEvent,
   TitleChangeEventDetail,
+  DialogChangeEventDetail,
 } from '../types/events';
 import {ViewState} from '../types/types';
+import {EventType} from '../utils/event-util';
+import {GerritView} from '../services/router/router-model';
+import {windowLocationReload} from '../utils/dom-util';
 
 interface ErrorInfo {
   text: string;
@@ -88,7 +89,6 @@
 
 export interface GrAppElement {
   $: {
-    restAPI: RestApiService & Element;
     router: GrRouter;
     errorManager: GrErrorManager;
     errorView: HTMLDivElement;
@@ -192,8 +192,22 @@
   @property({type: Boolean})
   loadKeyboardShortcutsDialog = false;
 
+  // TODO(milutin) - remove once new gr-dialog will do it out of the box
+  // This removes footer, header from a11y tree, when a dialog on view
+  // (e.g. reply dialog) is open
+  @property({type: Boolean})
+  _footerHeaderAriaHidden = false;
+
+  // TODO(milutin) - remove once new gr-dialog will do it out of the box
+  // This removes main page from a11y tree, when a dialog on gr-app-element
+  // (e.g. shortcut dialog) is open
+  @property({type: Boolean})
+  _mainAriaHidden = false;
+
   private reporting = appContext.reportingService;
 
+  private readonly restApiService = appContext.restApiService;
+
   keyboardShortcuts() {
     return {
       [Shortcut.OPEN_SHORTCUT_HELP_DIALOG]: '_showKeyboardShortcuts',
@@ -209,19 +223,26 @@
   created() {
     super.created();
     this._bindKeyboardShortcuts();
-    this.addEventListener('page-error', e => this._handlePageError(e));
-    this.addEventListener('title-change', e => this._handleTitleChange(e));
+    document.addEventListener(EventType.PAGE_ERROR, e => {
+      this._handlePageError(e);
+    });
+    this.addEventListener(EventType.TITLE_CHANGE, e => {
+      this._handleTitleChange(e);
+    });
+    this.addEventListener(EventType.DIALOG_CHANGE, e => {
+      this._handleDialogChange(e as CustomEvent<DialogChangeEventDetail>);
+    });
     this.addEventListener('location-change', e =>
       this._handleLocationChange(e)
     );
-    this.addEventListener('rpc-log', e => this._handleRpcLog(e));
+    document.addEventListener('gr-rpc-log', e => this._handleRpcLog(e));
     this.addEventListener('shortcut-triggered', e =>
       this._handleShortcutTriggered(e)
     );
     // Ideally individual views should handle this event and respond with a soft
     // reload. This is a catch-all for all views that cannot or have not
     // implemented that.
-    this.addEventListener('reload', () => window.location.reload());
+    this.addEventListener('reload', () => windowLocationReload());
   }
 
   /** @override */
@@ -231,19 +252,19 @@
     this.reporting.appStarted();
     this.$.router.start();
 
-    this.$.restAPI.getAccount().then(account => {
+    this.restApiService.getAccount().then(account => {
       this._account = account;
       const role = account ? 'user' : 'guest';
       this.reporting.reportLifeCycle(`Started as ${role}`);
     });
-    this.$.restAPI.getConfig().then(config => {
+    this.restApiService.getConfig().then(config => {
       this._serverConfig = config;
 
       if (config && config.gerrit && config.gerrit.report_bug_url) {
         this._feedbackUrl = config.gerrit.report_bug_url;
       }
     });
-    this.$.restAPI.getVersion().then(version => {
+    this.restApiService.getVersion().then(version => {
       this._version = version;
       this._logWelcome();
     });
@@ -272,9 +293,7 @@
         offset: 0,
         selectedChangeIndex: 0,
       },
-      dashboardView: {
-        selectedChangeIndex: 0,
-      },
+      dashboardView: {},
     };
   }
 
@@ -373,7 +392,7 @@
     }
     this.bindShortcut(Shortcut.NEXT_CHUNK, 'n');
     this.bindShortcut(Shortcut.PREV_CHUNK, 'p');
-    this.bindShortcut(Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x');
+    this.bindShortcut(Shortcut.TOGGLE_ALL_DIFF_CONTEXT, 'shift+x');
     this.bindShortcut(Shortcut.NEXT_COMMENT_THREAD, 'shift+n');
     this.bindShortcut(Shortcut.PREV_COMMENT_THREAD, 'shift+p');
     this.bindShortcut(
@@ -433,9 +452,9 @@
     if (!account) return;
 
     // Preferences are cached when a user is logged in; warm them.
-    this.$.restAPI.getPreferences();
-    this.$.restAPI.getDiffPreferences();
-    this.$.restAPI.getEditPreferences();
+    this.restApiService.getPreferences();
+    this.restApiService.getDiffPreferences();
+    this.restApiService.getEditPreferences();
     this.$.errorManager.knownAccountId =
       (this._account && this._account._account_id) || null;
   }
@@ -487,6 +506,9 @@
         registrationOverlay.refit();
       });
     }
+    // To fix bug announce read after each new view, we reset announce with
+    // empty space
+    this.fire('iron-announce', {text: ' '}, {bubbles: true});
   }
 
   _handleShortcutTriggered(event: ShortcutTriggeredEvent) {
@@ -499,11 +521,10 @@
     if (e.ctrlKey) key = 'ctrl+' + key;
     if (e.metaKey) key = 'meta+' + key;
     if (e.altKey) key = 'alt+' + key;
+    const path = event.composedPath();
     this.reporting.reportInteraction('shortcut-triggered', {
       key,
-      from:
-        (event.path && event.path[0] && (event.path[0] as Element).nodeName) ??
-        'unknown',
+      from: (path && path[0] && (path[0] as Element).nodeName) ?? 'unknown',
     });
   }
 
@@ -592,6 +613,14 @@
     }
   }
 
+  _handleDialogChange(e: CustomEvent<DialogChangeEventDetail>) {
+    if (e.detail.canceled) {
+      this._footerHeaderAriaHidden = false;
+    } else if (e.detail.opened) {
+      this._footerHeaderAriaHidden = true;
+    }
+  }
+
   handleShowKeyboardShortcuts() {
     this.loadKeyboardShortcutsDialog = true;
     flush();
@@ -608,17 +637,26 @@
     ) as GrOverlay;
     if (!keyboardShortcuts) return;
     if (keyboardShortcuts.opened) {
-      keyboardShortcuts.close();
+      keyboardShortcuts.cancel();
       return;
     }
     if (this.shouldSuppressKeyboardShortcut(e)) {
       return;
     }
     keyboardShortcuts.open();
+    this._footerHeaderAriaHidden = true;
+    this._mainAriaHidden = true;
   }
 
   _handleKeyboardShortcutDialogClose() {
-    (this.shadowRoot!.querySelector('#keyboardShortcuts') as GrOverlay).close();
+    (this.shadowRoot!.querySelector(
+      '#keyboardShortcuts'
+    ) as GrOverlay).cancel();
+  }
+
+  onOverlayCanceled() {
+    this._footerHeaderAriaHidden = false;
+    this._mainAriaHidden = false;
   }
 
   _handleAccountDetailUpdate() {
diff --git a/polygerrit-ui/app/elements/gr-app-element_html.ts b/polygerrit-ui/app/elements/gr-app-element_html.ts
index c1258fb..81a9988 100644
--- a/polygerrit-ui/app/elements/gr-app-element_html.ts
+++ b/polygerrit-ui/app/elements/gr-app-element_html.ts
@@ -104,9 +104,10 @@
     on-show-keyboard-shortcuts="handleShowKeyboardShortcuts"
     mobile-search-hidden="[[!mobileSearch]]"
     login-url="[[_loginUrl]]"
+    aria-hidden="[[_footerHeaderAriaHidden]]"
   >
   </gr-main-header>
-  <main>
+  <main aria-hidden="[[_mainAriaHidden]]">
     <template is="dom-if" if="[[mobileSearch]]">
       <gr-smart-search
         id="search"
@@ -176,7 +177,7 @@
       <div class="errorMoreInfo">[[_lastError.moreInfo]]</div>
     </div>
   </main>
-  <footer r="contentinfo">
+  <footer r="contentinfo" aria-hidden="[[_footerHeaderAriaHidden]]">
     <div>
       Powered by
       <a href="https://www.gerritcodereview.com/" rel="noopener" target="_blank"
@@ -201,7 +202,11 @@
     </div>
   </footer>
   <template is="dom-if" if="[[loadKeyboardShortcutsDialog]]">
-    <gr-overlay id="keyboardShortcuts" with-backdrop="">
+    <gr-overlay
+      id="keyboardShortcuts"
+      with-backdrop=""
+      on-iron-overlay-canceled="onOverlayCanceled"
+    >
       <gr-keyboard-shortcuts-dialog
         on-close="_handleKeyboardShortcutDialogClose"
       ></gr-keyboard-shortcuts-dialog>
@@ -223,10 +228,8 @@
     id="errorManager"
     login-url="[[_loginUrl]]"
   ></gr-error-manager>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   <gr-router id="router"></gr-router>
   <gr-plugin-host id="plugins" config="[[_serverConfig]]"> </gr-plugin-host>
-  <gr-lib-loader id="libLoader"></gr-lib-loader>
   <gr-external-style
     id="externalStyleForAll"
     name="app-theme"
diff --git a/polygerrit-ui/app/elements/gr-app-global-var-init.ts b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
index fac5f45..8a632bb 100644
--- a/polygerrit-ui/app/elements/gr-app-global-var-init.ts
+++ b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
@@ -22,61 +22,13 @@
  * expose these variables until plugins switch to direct import from polygerrit.
  */
 
-import {
-  getAccountDisplayName,
-  getDisplayName,
-  getGroupDisplayName,
-  getUserName,
-} from '../utils/display-name-util';
 import {GrAnnotation} from './diff/gr-diff-highlight/gr-annotation';
-import {GrAttributeHelper} from './plugins/gr-attribute-helper/gr-attribute-helper';
 import {GrDiffLine, GrDiffLineType} from './diff/gr-diff/gr-diff-line';
 import {GrDiffGroup, GrDiffGroupType} from './diff/gr-diff/gr-diff-group';
-import {GrDiffBuilder} from './diff/gr-diff-builder/gr-diff-builder';
-import {GrDiffBuilderSideBySide} from './diff/gr-diff-builder/gr-diff-builder-side-by-side';
-import {GrDiffBuilderImage} from './diff/gr-diff-builder/gr-diff-builder-image';
-import {GrDiffBuilderUnified} from './diff/gr-diff-builder/gr-diff-builder-unified';
-import {GrDiffBuilderBinary} from './diff/gr-diff-builder/gr-diff-builder-binary';
-import {GrChangeActionsInterface} from './shared/gr-js-api-interface/gr-change-actions-js-api';
-import {GrChangeReplyInterface} from './shared/gr-js-api-interface/gr-change-reply-js-api';
-import {GrEditConstants} from './edit/gr-edit-constants';
-import {
-  GrDomHooksManager,
-  GrDomHook,
-} from './plugins/gr-dom-hooks/gr-dom-hooks';
-import {GrEtagDecorator} from './shared/gr-rest-api-interface/gr-etag-decorator';
-import {GrThemeApi} from './plugins/gr-theme-api/gr-theme-api';
-import {
-  SiteBasedCache,
-  FetchPromisesCache,
-  GrRestApiHelper,
-} from './shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
-import {GrLinkTextParser} from './shared/gr-linked-text/link-text-parser';
-import {
-  getPluginEndpoints,
-  GrPluginEndpoints,
-} from './shared/gr-js-api-interface/gr-plugin-endpoints';
-import {GrReviewerUpdatesParser} from './shared/gr-rest-api-interface/gr-reviewer-updates-parser';
-import {GrPopupInterface} from './plugins/gr-popup-interface/gr-popup-interface';
-import {GrCountStringFormatter} from './shared/gr-count-string-formatter/gr-count-string-formatter';
-import {
-  GrReviewerSuggestionsProvider,
-  SUGGESTIONS_PROVIDERS_USERS_TYPES,
-} from '../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {getPluginEndpoints} from './shared/gr-js-api-interface/gr-plugin-endpoints';
 import {util} from '../scripts/util';
 import {page} from '../utils/page-wrapper-utils';
 import {appContext} from '../services/app-context';
-import {GrAdminApi} from './plugins/gr-admin-api/gr-admin-api';
-import {GrAnnotationActionsContext} from './shared/gr-js-api-interface/gr-annotation-actions-context';
-import {GrAnnotationActionsInterface} from './shared/gr-js-api-interface/gr-annotation-actions-js-api';
-import {GrChangeMetadataApi} from './plugins/gr-change-metadata-api/gr-change-metadata-api';
-import {GrEmailSuggestionsProvider} from '../scripts/gr-email-suggestions-provider/gr-email-suggestions-provider';
-import {GrGroupSuggestionsProvider} from '../scripts/gr-group-suggestions-provider/gr-group-suggestions-provider';
-import {GrEventHelper} from './plugins/gr-event-helper/gr-event-helper';
-import {GrPluginRestApi} from './shared/gr-js-api-interface/gr-plugin-rest-api';
-import {GrRepoApi} from './plugins/gr-repo-api/gr-repo-api';
-import {GrSettingsApi} from './plugins/gr-settings-api/gr-settings-api';
-import {GrStylesApi} from './plugins/gr-styles-api/gr-styles-api';
 import {
   getPluginLoader,
   PluginLoader,
@@ -84,7 +36,6 @@
 import {GrPluginActionContext} from './shared/gr-js-api-interface/gr-plugin-action-context';
 import {
   getPluginNameFromUrl,
-  getRestAPI,
   PLUGIN_LOADING_TIMEOUT_MS,
   PRELOADED_PROTOCOL,
   send,
@@ -92,67 +43,24 @@
 import {getBaseUrl} from '../utils/url-util';
 import {GerritNav} from './core/gr-navigation/gr-navigation';
 import {getRootElement} from '../scripts/rootElement';
-import {rangesEqual} from './diff/gr-diff/gr-diff-utils';
 import {RevisionInfo} from './shared/revision-info/revision-info';
-import {CoverageType} from '../types/types';
-import {_setHiddenScroll, getHiddenScroll} from '../scripts/hiddenscroll';
 
 export function initGlobalVariables() {
-  window.GrDisplayNameUtils = {
-    getUserName,
-    getDisplayName,
-    getAccountDisplayName,
-    getGroupDisplayName,
-  };
   window.GrAnnotation = GrAnnotation;
-  window.GrAttributeHelper = GrAttributeHelper;
   window.GrDiffLine = GrDiffLine;
   window.GrDiffLineType = GrDiffLineType;
   window.GrDiffGroup = GrDiffGroup;
   window.GrDiffGroupType = GrDiffGroupType;
-  window.GrDiffBuilder = GrDiffBuilder;
-  window.GrDiffBuilderSideBySide = GrDiffBuilderSideBySide;
-  window.GrDiffBuilderImage = GrDiffBuilderImage;
-  window.GrDiffBuilderUnified = GrDiffBuilderUnified;
-  window.GrDiffBuilderBinary = GrDiffBuilderBinary;
-  window.GrChangeActionsInterface = GrChangeActionsInterface;
-  window.GrChangeReplyInterface = GrChangeReplyInterface;
-  window.GrEditConstants = GrEditConstants;
-  window.GrDomHooksManager = GrDomHooksManager;
-  window.GrDomHook = GrDomHook;
-  window.GrEtagDecorator = GrEtagDecorator;
-  window.GrThemeApi = GrThemeApi;
-  window.SiteBasedCache = SiteBasedCache;
-  window.FetchPromisesCache = FetchPromisesCache;
-  window.GrRestApiHelper = GrRestApiHelper;
-  window.GrLinkTextParser = GrLinkTextParser;
-  window.GrPluginEndpoints = GrPluginEndpoints;
-  window.GrReviewerUpdatesParser = GrReviewerUpdatesParser;
-  window.GrPopupInterface = GrPopupInterface;
-  window.GrCountStringFormatter = GrCountStringFormatter;
-  window.GrReviewerSuggestionsProvider = GrReviewerSuggestionsProvider;
   window.util = util;
   window.page = page;
   window.Auth = appContext.authService;
   window.EventEmitter = appContext.eventEmitter;
-  window.GrAdminApi = GrAdminApi;
-  window.GrAnnotationActionsContext = GrAnnotationActionsContext;
-  window.GrAnnotationActionsInterface = GrAnnotationActionsInterface;
-  window.GrChangeMetadataApi = GrChangeMetadataApi;
-  window.GrEmailSuggestionsProvider = GrEmailSuggestionsProvider;
-  window.GrGroupSuggestionsProvider = GrGroupSuggestionsProvider;
-  window.GrEventHelper = GrEventHelper;
-  window.GrPluginRestApi = GrPluginRestApi;
-  window.GrRepoApi = GrRepoApi;
-  window.GrSettingsApi = GrSettingsApi;
-  window.GrStylesApi = GrStylesApi;
   window.PluginLoader = PluginLoader;
   window.GrPluginActionContext = GrPluginActionContext;
 
   window._apiUtils = {
     getPluginNameFromUrl,
     send,
-    getRestAPI,
     getBaseUrl,
     PRELOADED_PROTOCOL,
     PLUGIN_LOADING_TIMEOUT_MS,
@@ -167,14 +75,5 @@
   // TODO: should define as a getter
   window.Gerrit._endpoints = getPluginEndpoints();
 
-  // TODO(TS): seems not used, probably just remove
-  window.Gerrit.slotToContent = (slot: any) => slot;
-  window.Gerrit.rangesEqual = rangesEqual;
-  window.Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES = SUGGESTIONS_PROVIDERS_USERS_TYPES;
   window.Gerrit.RevisionInfo = RevisionInfo;
-  window.Gerrit.CoverageType = CoverageType;
-  Object.defineProperty(window.Gerrit, 'hiddenscroll', {
-    get: getHiddenScroll,
-    set: _setHiddenScroll,
-  });
 }
diff --git a/polygerrit-ui/app/elements/gr-app-init.ts b/polygerrit-ui/app/elements/gr-app-init.ts
index 6d79ce1..ab38326 100644
--- a/polygerrit-ui/app/elements/gr-app-init.ts
+++ b/polygerrit-ui/app/elements/gr-app-init.ts
@@ -22,17 +22,6 @@
 } from '../services/gr-reporting/gr-reporting_impl';
 import {appContext} from '../services/app-context';
 
-interface UninitializedPolymer {
-  lazyRegister: boolean;
-}
-
-if (!window.Polymer) {
-  // Without as... it violates internal google rules.
-  ((window.Polymer as unknown) as UninitializedPolymer) = {
-    lazyRegister: true,
-  };
-}
-
 initAppContext();
 initVisibilityReporter(appContext);
 initPerformanceReporter(appContext);
diff --git a/polygerrit-ui/app/elements/gr-app-types.ts b/polygerrit-ui/app/elements/gr-app-types.ts
index b05117f..4809562 100644
--- a/polygerrit-ui/app/elements/gr-app-types.ts
+++ b/polygerrit-ui/app/elements/gr-app-types.ts
@@ -16,7 +16,6 @@
  */
 import {
   GenerateUrlParameters,
-  GerritView,
   GroupDetailView,
   RepoDetailView,
 } from './core/gr-navigation/gr-navigation';
@@ -28,6 +27,7 @@
   RepoName,
   UrlEncodedCommentId,
 } from '../types/common';
+import {GerritView} from '../services/router/router-model';
 
 export interface AppElement extends HTMLElement {
   params: AppElementParams | GenerateUrlParameters;
diff --git a/polygerrit-ui/app/elements/gr-app_test.js b/polygerrit-ui/app/elements/gr-app_test.js
index 4c5e965..1a04411 100644
--- a/polygerrit-ui/app/elements/gr-app_test.js
+++ b/polygerrit-ui/app/elements/gr-app_test.js
@@ -20,11 +20,13 @@
 import {appContext} from '../services/app-context.js';
 import {GerritNav} from './core/gr-navigation/gr-navigation.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {stubRestApi} from '../test/test-utils.js';
 
 const basicFixture = fixtureFromTemplate(html`<gr-app id="app"></gr-app>`);
 
 suite('gr-app tests', () => {
   let element;
+  let configStub;
 
   setup(done => {
     sinon.stub(appContext.reportingService, 'appStarted');
@@ -34,23 +36,17 @@
     stub('gr-router', {
       start: sinon.stub(),
     });
-    stub('gr-rest-api-interface', {
-      getAccount() { return Promise.resolve({}); },
-      getAccountCapabilities() { return Promise.resolve({}); },
-      getConfig() {
-        return Promise.resolve({
-          plugin: {},
-          auth: {
-            auth_type: undefined,
-          },
-        });
+    stubRestApi('getAccount').returns(Promise.resolve({}));
+    stubRestApi('getAccountCapabilities').returns(Promise.resolve({}));
+    configStub = stubRestApi('getConfig').returns(Promise.resolve({
+      plugin: {},
+      auth: {
+        auth_type: undefined,
       },
-      getPreferences() { return Promise.resolve({my: []}); },
-      getDiffPreferences() { return Promise.resolve({}); },
-      getEditPreferences() { return Promise.resolve({}); },
-      getVersion() { return Promise.resolve(42); },
-      probePath() { return Promise.resolve(42); },
-    });
+    }));
+    stubRestApi('getPreferences').returns(Promise.resolve({my: []}));
+    stubRestApi('getVersion').returns(Promise.resolve(42));
+    stubRestApi('probePath').returns(Promise.resolve(42));
 
     element = basicFixture.instantiate();
     flush(done);
@@ -69,12 +65,11 @@
     sinon.assert.callOrder(appStartedStub, routerStartStub);
   });
 
-  test('passes config to gr-plugin-host', () => {
-    const config = appElement().$.restAPI.getConfig;
-    return config.lastCall.returnValue.then(config => {
+  test('passes config to gr-plugin-host', () =>
+    configStub.lastCall.returnValue.then(config => {
       assert.deepEqual(appElement().$.plugins.config, config);
-    });
-  });
+    })
+  );
 
   test('_paramsChanged sets search page', () => {
     appElement()._paramsChanged({base: {view: GerritNav.View.CHANGE}});
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-admin-api/gr-admin-api.ts b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts
index 1332118..897be67 100644
--- a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts
@@ -14,27 +14,20 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
-import {PluginApi} from '../gr-plugin-types';
-
-/** Interface for menu link */
-export interface MenuLink {
-  text: string;
-  url: string;
-  capability: string | null;
-}
+import {EventType, PluginApi} from '../../../api/plugin';
+import {AdminPluginApi, MenuLink} from '../../../api/admin';
 
 /**
  * GrAdminApi class.
  *
  * Defines common methods to register / retrieve menu links.
  */
-export class GrAdminApi {
+export class GrAdminApi implements AdminPluginApi {
   // TODO(TS): maybe define as enum if its a limited set
   private menuLinks: MenuLink[] = [];
 
   constructor(private readonly plugin: PluginApi) {
-    this.plugin.on('admin-menu-links', this);
+    this.plugin.on(EventType.ADMIN_MENU_LINKS, this);
   }
 
   addMenuLink(text: string, url: string, capability?: string) {
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts
index 6fc7a17..e0b4ee9 100644
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts
@@ -14,11 +14,14 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {AttributeHelperPluginApi} from '../../../api/attribute-helper';
 
-export class GrAttributeHelper {
+export class GrAttributeHelper implements AttributeHelperPluginApi {
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   private readonly _promises = new Map<string, Promise<any>>();
 
-  // TOOD(TS): Change any to something more like HTMLElement.
+  // TODO(TS): Change any to something more like HTMLElement.
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   constructor(public element: any) {}
 
   _getChangedEventName(name: string): string {
@@ -32,6 +35,7 @@
     return this.element[name] !== undefined;
   }
 
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   _reportValue(callback: (value: any) => void, value: any) {
     try {
       callback(value);
@@ -46,6 +50,7 @@
    * @param name Property name.
    * @return Unbind function.
    */
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   bind(name: string, callback: (value: any) => void) {
     const attributeChangedEventName = this._getChangedEventName(name);
     const changedHandler = (e: CustomEvent) =>
@@ -71,6 +76,7 @@
       return Promise.resolve(this.element[name]);
     }
     if (!this._promises.has(name)) {
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
       let resolve: (value: any) => void;
       const promise = new Promise(r => (resolve = r));
       const unbind = this.bind(name, value => {
@@ -85,6 +91,7 @@
   /**
    * Sets value and dispatches event to force notify.
    */
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   set(name: string, value: any) {
     this.element[name] = value;
     this.element.dispatchEvent(
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js
index ea7bdc3..7ea3be3 100644
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js
@@ -20,7 +20,7 @@
 import {GrAttributeHelper} from './gr-attribute-helper.js';
 
 Polymer({
-  is: 'gr-attrubute-helper-some-element',
+  is: 'gr-attribute-helper-some-element',
   properties: {
     fooBar: {
       type: Object,
@@ -29,7 +29,7 @@
   },
 });
 
-const basicFixture = fixtureFromElement('gr-attrubute-helper-some-element');
+const basicFixture = fixtureFromElement('gr-attribute-helper-some-element');
 
 suite('gr-attribute-helper tests', () => {
   let element;
diff --git a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts
index 322d32e..a03c5dc 100644
--- a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts
@@ -14,27 +14,29 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {HookApi, PluginApi} from '../gr-plugin-types';
+import {PluginApi} from '../../../api/plugin';
+import {ChangeMetadataPluginApi} from '../../../api/change-metadata';
+import {HookApi} from '../../../api/hook';
 
-export class GrChangeMetadataApi {
-  private _hook: HookApi | null;
+export class GrChangeMetadataApi implements ChangeMetadataPluginApi {
+  private hook: HookApi | null;
 
   public plugin: PluginApi;
 
   constructor(plugin: PluginApi) {
     this.plugin = plugin;
-    this._hook = null;
+    this.hook = null;
   }
 
   _createHook() {
-    this._hook = this.plugin.hook('change-metadata-item');
+    this.hook = this.plugin.hook('change-metadata-item');
   }
 
   onLabelsChanged(callback: (value: unknown) => void) {
-    if (!this._hook) {
+    if (!this.hook) {
       this._createHook();
     }
-    this._hook!.onAttached((element: Element) =>
+    this.hook!.onAttached((element: Element) =>
       this.plugin.attributeHelper(element).bind('labels', callback)
     );
     return this;
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..404fc71
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
@@ -0,0 +1,62 @@
+/**
+ * @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 '../../../api/plugin';
+import {
+  ChecksApiConfig,
+  ChecksProvider,
+  ChecksPluginApi,
+} from '../../../api/checks';
+import {appContext} from '../../../services/app-context';
+
+const DEFAULT_CONFIG: ChecksApiConfig = {
+  fetchPollingIntervalSeconds: 60,
+};
+
+enum State {
+  NOT_REGISTERED,
+  REGISTERED,
+}
+
+/**
+ * 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 ChecksPluginApi {
+  private state = State.NOT_REGISTERED;
+
+  private readonly checksService = appContext.checksService;
+
+  constructor(readonly plugin: PluginApi) {}
+
+  announceUpdate() {
+    this.checksService.reload(this.plugin.getPluginName());
+  }
+
+  register(provider: ChecksProvider, config?: ChecksApiConfig): void {
+    if (this.state === State.REGISTERED)
+      throw new Error('Only one provider can be registered per plugin.');
+    this.state = State.REGISTERED;
+    this.checksService.register(
+      this.plugin.getPluginName(),
+      provider,
+      config ?? DEFAULT_CONFIG
+    );
+  }
+}
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..3ab3efb
--- /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 {PluginApi} from '../../../api/plugin';
+import {ChecksPluginApi} from '../../../api/checks';
+
+const gerritPluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-settings-api tests', () => {
+  let checksApi: ChecksPluginApi | 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-dom-hooks/gr-dom-hooks.ts b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts
index dd76be4..3e8f0a4 100644
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts
@@ -15,16 +15,17 @@
  * limitations under the License.
  */
 import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {HookApi, HookCallback, PluginApi} from '../gr-plugin-types';
+import {PluginApi} from '../../../api/plugin';
+import {HookApi, HookCallback} from '../../../api/hook';
 
 export class GrDomHooksManager {
-  private _hooks: Record<string, GrDomHook>;
+  private hooks: Record<string, GrDomHook>;
 
-  private _plugin: PluginApi;
+  private plugin: PluginApi;
 
   constructor(plugin: PluginApi) {
-    this._plugin = plugin;
-    this._hooks = {};
+    this.plugin = plugin;
+    this.hooks = {};
   }
 
   _getHookName(endpointName: string, moduleName?: string) {
@@ -35,37 +36,36 @@
       // TODO: this still can not prevent if plugin has invalid char
       // other than uppercase, but is the first step
       // https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name
-      const pluginName: string =
-        this._plugin.getPluginName() || 'unknown_plugin';
+      const pluginName: string = this.plugin.getPluginName() || 'unknownplugin';
       return pluginName.toLowerCase() + '-autogenerated-' + endpointName;
     }
   }
 
   getDomHook(endpointName: string, moduleName?: string) {
     const hookName = this._getHookName(endpointName, moduleName);
-    if (!this._hooks[hookName]) {
-      this._hooks[hookName] = new GrDomHook(hookName, moduleName);
+    if (!this.hooks[hookName]) {
+      this.hooks[hookName] = new GrDomHook(hookName, moduleName);
     }
-    return this._hooks[hookName];
+    return this.hooks[hookName];
   }
 }
 
 export class GrDomHook implements HookApi {
-  private _instances: HTMLElement[] = [];
+  private instances: HTMLElement[] = [];
 
-  private _attachCallbacks: HookCallback[] = [];
+  private attachCallbacks: HookCallback[] = [];
 
-  private _detachCallbacks: HookCallback[] = [];
+  private detachCallbacks: HookCallback[] = [];
 
-  private _moduleName: string;
+  private moduleName: string;
 
-  private _lastAttachedPromise: Promise<HTMLElement> | null = null;
+  private lastAttachedPromise: Promise<HTMLElement> | null = null;
 
   constructor(hookName: string, moduleName?: string) {
     if (moduleName) {
-      this._moduleName = moduleName;
+      this.moduleName = moduleName;
     } else {
-      this._moduleName = hookName;
+      this.moduleName = hookName;
       this._createPlaceholder(hookName);
     }
   }
@@ -88,16 +88,16 @@
   }
 
   handleInstanceDetached(instance: HTMLElement) {
-    const index = this._instances.indexOf(instance);
+    const index = this.instances.indexOf(instance);
     if (index !== -1) {
-      this._instances.splice(index, 1);
+      this.instances.splice(index, 1);
     }
-    this._detachCallbacks.forEach(callback => callback(instance));
+    this.detachCallbacks.forEach(callback => callback(instance));
   }
 
   handleInstanceAttached(instance: HTMLElement) {
-    this._instances.push(instance);
-    this._attachCallbacks.forEach(callback => callback(instance));
+    this.instances.push(instance);
+    this.attachCallbacks.forEach(callback => callback(instance));
   }
 
   /**
@@ -105,32 +105,32 @@
    * Returns a Promise, that's resolved when attachment is done.
    */
   getLastAttached(): Promise<HTMLElement> {
-    if (this._instances.length) {
-      return Promise.resolve(this._instances.slice(-1)[0]);
+    if (this.instances.length) {
+      return Promise.resolve(this.instances.slice(-1)[0]);
     }
-    if (!this._lastAttachedPromise) {
+    if (!this.lastAttachedPromise) {
       let resolve: HookCallback;
       const promise = new Promise<HTMLElement>(r => {
         resolve = r;
-        this._attachCallbacks.push(resolve);
+        this.attachCallbacks.push(resolve);
       });
-      this._lastAttachedPromise = promise.then((element: HTMLElement) => {
-        this._lastAttachedPromise = null;
-        const index = this._attachCallbacks.indexOf(resolve);
+      this.lastAttachedPromise = promise.then((element: HTMLElement) => {
+        this.lastAttachedPromise = null;
+        const index = this.attachCallbacks.indexOf(resolve);
         if (index !== -1) {
-          this._attachCallbacks.splice(index, 1);
+          this.attachCallbacks.splice(index, 1);
         }
         return element;
       });
     }
-    return this._lastAttachedPromise;
+    return this.lastAttachedPromise;
   }
 
   /**
    * Get all DOM hook elements.
    */
   getAllAttached() {
-    return this._instances;
+    return this.instances;
   }
 
   /**
@@ -138,7 +138,7 @@
    * is attached.
    */
   onAttached(callback: HookCallback) {
-    this._attachCallbacks.push(callback);
+    this.attachCallbacks.push(callback);
     return this;
   }
 
@@ -148,7 +148,7 @@
    *
    */
   onDetached(callback: HookCallback) {
-    this._detachCallbacks.push(callback);
+    this.detachCallbacks.push(callback);
     return this;
   }
 
@@ -156,6 +156,6 @@
    * Name of DOM hook element that will be installed into the endpoint.
    */
   getModuleName() {
-    return this._moduleName;
+    return this.moduleName;
   }
 }
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js
index 49223b9..883f2a6 100644
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js
@@ -45,7 +45,7 @@
     });
 
     test('getModuleName()', () => {
-      const hookName = Object.keys(instance._hooks).pop();
+      const hookName = Object.keys(instance.hooks).pop();
       assert.equal(hookName, 'testplugin-autogenerated-foo-bar');
       assert.equal(hook.getModuleName(), 'testplugin-autogenerated-foo-bar');
     });
@@ -57,7 +57,7 @@
     });
 
     test('getModuleName()', () => {
-      const hookName = Object.keys(instance._hooks).pop();
+      const hookName = Object.keys(instance.hooks).pop();
       assert.equal(hookName, 'foo-bar my-el');
       assert.equal(hook.getModuleName(), 'my-el');
     });
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
index 3a83729..423cff9 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../shared/gr-js-api-interface/gr-js-api-interface';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
@@ -25,12 +24,13 @@
 } from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {customElement, property} from '@polymer/decorators';
-import {HookApi, PluginApi} from '../gr-plugin-types';
+import {PluginApi} from '../../../api/plugin';
+import {HookApi} from '../../../api/hook';
 
 const INIT_PROPERTIES_TIMEOUT_MS = 10000;
 
 @customElement('gr-endpoint-decorator')
-class GrEndpointDecorator extends GestureEventListeners(
+export class GrEndpointDecorator extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
@@ -125,12 +125,13 @@
           )
         );
     });
-    // TODO(TS): Should be a number, but TS thinks that is must be some weird
-    // NodeJS.Timeout object.
-    let timeoutId: any;
+    let timeoutId: number;
     const timeout = new Promise(
       () =>
-        (timeoutId = setTimeout(() => {
+        // specify window here so that TS pulls the correct setTimeout method
+        // if window is not specified, then the function is pulled from node
+        // and the return type is NodeJS.Timeout object
+        (timeoutId = window.setTimeout(() => {
           console.warn(
             'Timeout waiting for endpoint properties initialization: ' +
               `plugin ${plugin.getPluginName()}, endpoint ${this.name}`
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
index 02fcdec..4b34d56 100644
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
@@ -14,13 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {
+  EventHelperPluginApi,
+  UnsubscribeCallback,
+} from '../../../api/event-helper';
 
 export interface ListenOptions {
   event?: string;
   capture?: boolean;
 }
 
-export class GrEventHelper {
+export class GrEventHelper implements EventHelperPluginApi {
   constructor(readonly element: HTMLElement) {}
 
   /**
@@ -50,7 +54,7 @@
    * Alias for @see captureClick
    */
   captureTap(callback: (event: Event) => boolean) {
-    this.captureClick(callback);
+    return this.captureClick(callback);
   }
 
   /**
@@ -68,12 +72,13 @@
     container: HTMLElement,
     callback: (event: Event) => boolean,
     options?: ListenOptions | null
-  ) {
+  ): UnsubscribeCallback {
     const capture = options?.capture;
     const event = options?.event || 'click';
     const handler = (e: Event) => {
-      if (!e.path) return;
-      if (e.path.indexOf(this.element) !== -1) {
+      const path = e.composedPath();
+      if (!path) return;
+      if (path.indexOf(this.element) !== -1) {
         let mayContinue = true;
         try {
           mayContinue = callback(e);
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts
index 02786dd..dc3ebcf 100644
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../shared/gr-js-api-interface/gr-js-api-interface';
 import {updateStyles} from '@polymer/polymer/lib/mixins/element-mixin';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts
index ed84406..81b3a16 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../shared/gr-js-api-interface/gr-js-api-interface';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-types.ts b/polygerrit-ui/app/elements/plugins/gr-plugin-types.ts
deleted file mode 100644
index 410da215..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-types.ts
+++ /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.
- */
-import {GrAttributeHelper} from './gr-attribute-helper/gr-attribute-helper';
-import {GrPluginRestApi} from '../shared/gr-js-api-interface/gr-plugin-rest-api';
-import {GrEventHelper} from './gr-event-helper/gr-event-helper';
-import {GrPopupInterface} from './gr-popup-interface/gr-popup-interface';
-import {ConfigInfo} from '../../types/common';
-
-interface GerritElementExtensions {
-  content?: HTMLElement & {hidden?: boolean};
-  change?: unknown;
-  revision?: unknown;
-  token?: string;
-  repoName?: string;
-  config?: ConfigInfo;
-}
-export type HookCallback = (el: HTMLElement & GerritElementExtensions) => void;
-
-export interface HookApi {
-  onAttached(callback: HookCallback): HookApi;
-  onDetached(callback: HookCallback): HookApi;
-  getAllAttached(): HTMLElement[];
-  getLastAttached(): Promise<HTMLElement>;
-  getModuleName(): string;
-  handleInstanceDetached(instance: HTMLElement): void;
-  handleInstanceAttached(instance: HTMLElement): void;
-}
-
-export enum TargetElement {
-  CHANGE_ACTIONS = 'changeactions',
-  REPLY_DIALOG = 'replydialog',
-}
-
-// Note: for new events, naming convention should be: `a-b`
-export enum EventType {
-  HISTORY = 'history',
-  LABEL_CHANGE = 'labelchange',
-  SHOW_CHANGE = 'showchange',
-  SUBMIT_CHANGE = 'submitchange',
-  SHOW_REVISION_ACTIONS = 'show-revision-actions',
-  COMMIT_MSG_EDIT = 'commitmsgedit',
-  COMMENT = 'comment',
-  REVERT = 'revert',
-  REVERT_SUBMISSION = 'revert_submission',
-  POST_REVERT = 'postrevert',
-  ANNOTATE_DIFF = 'annotatediff',
-  ADMIN_MENU_LINKS = 'admin-menu-links',
-  HIGHLIGHTJS_LOADED = 'highlightjs-loaded',
-}
-
-export interface RegisterOptions {
-  slot?: string;
-  replace: unknown;
-}
-
-export interface PanelInfo {
-  body: Element;
-  p: {[key: string]: any};
-  onUnload: () => void;
-}
-
-export interface SettingsInfo {
-  body: Element;
-  token?: string;
-  onUnload: () => void;
-  setTitle: () => void;
-  setWindowTitle: () => void;
-  show: () => void;
-}
-
-export interface PluginApi {
-  _url?: URL;
-  popup(): Promise<GrPopupInterface>;
-  popup(moduleName: string): Promise<GrPopupInterface>;
-  popup(moduleName?: string): Promise<GrPopupInterface | null>;
-  hook(endpointName: string, opt_options?: RegisterOptions): HookApi;
-  getPluginName(): string;
-  on(eventName: string, target: any): void;
-  attributeHelper(element: Element): GrAttributeHelper;
-  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.ts b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts
index d45c263..dcabc80 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts
@@ -17,7 +17,8 @@
 import './gr-plugin-popup';
 import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {GrPluginPopup} from './gr-plugin-popup';
-import {PluginApi} from '../gr-plugin-types';
+import {PluginApi} from '../../../api/plugin';
+import {PopupPluginApi} from '../../../api/popup';
 
 interface CustomPolymerPluginEl extends HTMLElement {
   plugin: PluginApi;
@@ -26,23 +27,23 @@
 /**
  * Plugin popup API.
  * Provides method for opening and closing popups from plugin.
- * opt_moduleName is a name of custom element that will be automatically
+ * optmoduleName is a name of custom element that will be automatically
  * inserted on popup opening.
  */
-export class GrPopupInterface {
-  private _openingPromise: Promise<GrPopupInterface> | null = null;
+export class GrPopupInterface implements PopupPluginApi {
+  private openingPromise: Promise<GrPopupInterface> | null = null;
 
-  private _popup: GrPluginPopup | null = null;
+  private popup: GrPluginPopup | null = null;
 
   constructor(
     readonly plugin: PluginApi,
-    private _moduleName: string | null = null
+    private moduleName: string | null = null
   ) {}
 
   _getElement() {
     // TODO(TS): maybe consider removing this if no one is using
     // anything other than native methods on the return
-    return (dom(this._popup) as unknown) as HTMLElement;
+    return (dom(this.popup) as unknown) as HTMLElement;
   }
 
   /**
@@ -50,35 +51,35 @@
    * Creates the popup if not previously created. Creates popup content element,
    * if it was provided with constructor.
    */
-  open(): Promise<GrPopupInterface> {
-    if (!this._openingPromise) {
-      this._openingPromise = this.plugin
+  open(): Promise<PopupPluginApi> {
+    if (!this.openingPromise) {
+      this.openingPromise = this.plugin
         .hook('plugin-overlay')
         .getLastAttached()
         .then(hookEl => {
           const popup = document.createElement('gr-plugin-popup');
-          if (this._moduleName) {
+          if (this.moduleName) {
             const el = popup.appendChild(
-              document.createElement(this._moduleName) as CustomPolymerPluginEl
+              document.createElement(this.moduleName) as CustomPolymerPluginEl
             );
             el.plugin = this.plugin;
           }
-          this._popup = hookEl.appendChild(popup);
+          this.popup = hookEl.appendChild(popup);
           flush();
-          return this._popup.open().then(() => this);
+          return this.popup.open().then(() => this);
         });
     }
-    return this._openingPromise;
+    return this.openingPromise;
   }
 
   /**
    * Hides the popup.
    */
   close() {
-    if (!this._popup) {
+    if (!this.popup) {
       return;
     }
-    this._popup.close();
-    this._openingPromise = null;
+    this.popup.close();
+    this.openingPromise = null;
   }
 }
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 be8836b..8a7788f 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,6 @@
 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.js';
 
 class GrUserTestPopupElement extends PolymerElement {
   static get is() { return 'gr-user-test-popup'; }
@@ -40,10 +39,8 @@
   let container;
   let instance;
   let plugin;
-  let ironOverlayBackdropStyleEl;
 
   setup(() => {
-    ironOverlayBackdropStyleEl = createIronOverlayBackdropStyleEl();
     pluginApi.install(p => { plugin = p; }, '0.1',
         'http://test.com/plugins/testplugin/static/test.js');
     container = containerFixture.instantiate();
@@ -54,10 +51,6 @@
     });
   });
 
-  teardown(() => {
-    ironOverlayBackdropStyleEl.remove();
-  });
-
   suite('manual', () => {
     setup(() => {
       instance = new GrPopupInterface(plugin);
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.ts b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.ts
index b3a40c5..a9aba13 100644
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.ts
@@ -17,6 +17,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-plugin-repo-command_html';
 import {customElement, property} from '@polymer/decorators';
+import {fireEvent} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -33,8 +34,6 @@
   }
 
   _handleClick() {
-    this.dispatchEvent(
-      new CustomEvent('command-tap', {composed: true, bubbles: true})
-    );
+    fireEvent(this, 'command-tap');
   }
 }
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.ts b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.ts
index 701a560..0418edb 100644
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.ts
@@ -16,9 +16,9 @@
  */
 import './gr-plugin-repo-command';
 import {ConfigInfo} from '../../../types/common';
-import {HookApi, PluginApi} from '../gr-plugin-types';
-
-type RepoCommandCallback = (repo?: string, config?: ConfigInfo) => boolean;
+import {PluginApi} from '../../../api/plugin';
+import {RepoCommandCallback, RepoPluginApi} from '../../../api/repo';
+import {HookApi} from '../../../api/hook';
 
 /**
  * Parameters provided on repo-command endpoint
@@ -28,8 +28,8 @@
   config: ConfigInfo;
 }
 
-export class GrRepoApi {
-  private _hook?: HookApi;
+export class GrRepoApi implements RepoPluginApi {
+  private hook?: HookApi;
 
   constructor(readonly plugin: PluginApi) {}
 
@@ -43,12 +43,12 @@
   }
 
   createCommand(title: string, callback: RepoCommandCallback) {
-    if (this._hook) {
+    if (this.hook) {
       console.warn('Already set up.');
-      return this._hook;
+      return this;
     }
-    this._hook = this._createHook(title);
-    this._hook.onAttached(element => {
+    this.hook = this._createHook(title);
+    this.hook.onAttached(element => {
       if (callback(element.repoName, element.config) === false) {
         element.hidden = true;
       }
@@ -57,11 +57,11 @@
   }
 
   onTap(callback: (event: Event) => boolean) {
-    if (!this._hook) {
+    if (!this.hook) {
       console.warn('Call createCommand first.');
       return this;
     }
-    this._hook.onAttached(element => {
+    this.hook.onAttached(element => {
       this.plugin.eventHelper(element).on('command-tap', callback);
     });
     return this;
diff --git a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.ts b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.ts
index c7f1ecd..4bdd40e 100644
--- a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.ts
@@ -16,9 +16,10 @@
  */
 import '../../settings/gr-settings-view/gr-settings-item';
 import '../../settings/gr-settings-view/gr-settings-menu-item';
-import {PluginApi} from '../gr-plugin-types';
+import {PluginApi} from '../../../api/plugin';
+import {SettingsPluginApi} from '../../../api/settings';
 
-export class GrSettingsApi {
+export class GrSettingsApi implements SettingsPluginApi {
   private _token: string;
 
   private _title = '(no title)';
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..a91b8d3 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
@@ -14,6 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {StyleObject, StylesPluginApi} from '../../../api/styles';
 
 /**
  * @fileoverview We should consider dropping support for this API:
@@ -22,13 +23,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 {
@@ -37,7 +31,7 @@
   };
 }
 
-export class GrStyleObject {
+export class GrStyleObject implements StyleObject {
   private className = '';
 
   constructor(private readonly rulesStr: string) {
@@ -50,10 +44,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;
     }
@@ -74,7 +67,6 @@
 
   /**
    * Apply shared style to the element.
-   *
    */
   apply(element: Element) {
     element.classList.add(this.getClassName(element));
@@ -84,7 +76,7 @@
 /**
  * TODO(TS): move to util
  */
-export class GrStylesApi {
+export class GrStylesApi implements StylesPluginApi {
   /**
    * Creates a new GrStyleObject with specified style properties.
    */
diff --git a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.js b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.js
index c41b551..1c606cd 100644
--- a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.js
@@ -172,12 +172,10 @@
     }
 
     function assertDisplayPropertyValues(elements, expectedDisplayValues) {
-      for (const key in elements) {
-        if (elements.hasOwnProperty(key)) {
-          assert.equal(
-              getComputedStyle(elements[key]).getPropertyValue('display'),
-              expectedDisplayValues[key]);
-        }
+      for (let i = 0; i < elements.length; i++) {
+        assert.equal(
+            getComputedStyle(elements[i]).getPropertyValue('display'),
+            expectedDisplayValues[i]);
       }
     }
   });
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.ts b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.ts
index 821e4bf..894ec6c 100644
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.ts
@@ -16,12 +16,13 @@
  */
 import './gr-custom-plugin-header';
 import {GrCustomPluginHeader} from './gr-custom-plugin-header';
-import {PluginApi} from '../gr-plugin-types';
+import {PluginApi} from '../../../api/plugin';
+import {ThemePluginApi} from '../../../api/theme';
 
 /**
  * Defines api for theme, can be used to set header logo and title.
  */
-export class GrThemeApi {
+export class GrThemeApi implements ThemePluginApi {
   constructor(private readonly plugin: PluginApi) {}
 
   setHeaderLogoAndTitle(logoUrl: string, title: string) {
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 9c781c8..5786576 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
@@ -17,7 +17,6 @@
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-avatar/gr-avatar';
 import '../../shared/gr-date-formatter/gr-date-formatter';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
@@ -26,14 +25,10 @@
 import {htmlTemplate} from './gr-account-info_html';
 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';
+import {appContext} from '../../../services/app-context';
+import {fireEvent} from '../../../utils/event-util';
 
-export interface GrAccountInfo {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
 @customElement('gr-account-info')
 export class GrAccountInfo extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
@@ -101,19 +96,21 @@
   @property({type: String})
   _avatarChangeUrl = '';
 
+  private readonly restApiService = appContext.restApiService;
+
   loadData() {
     const promises = [];
 
     this._loading = true;
 
     promises.push(
-      this.$.restAPI.getConfig().then(config => {
+      this.restApiService.getConfig().then(config => {
         this._serverConfig = config;
       })
     );
 
     promises.push(
-      this.$.restAPI.getAccount().then(account => {
+      this.restApiService.getAccount().then(account => {
         if (!account) return;
         this._hasNameChange = false;
         this._hasUsernameChange = false;
@@ -128,7 +125,7 @@
     );
 
     promises.push(
-      this.$.restAPI.getAvatarChangeUrl().then(url => {
+      this.restApiService.getAvatarChangeUrl().then(url => {
         this._avatarChangeUrl = url || '';
       })
     );
@@ -155,12 +152,7 @@
         this._hasDisplayNameChange = false;
         this._hasStatusChange = false;
         this._saving = false;
-        this.dispatchEvent(
-          new CustomEvent('account-detail-update', {
-            composed: true,
-            bubbles: true,
-          })
-        );
+        fireEvent(this, 'account-detail-update');
       });
   }
 
@@ -168,7 +160,7 @@
     // Note that we are intentionally not acting on this._account.name being the
     // empty string (which is falsy).
     return this._hasNameChange && this.nameMutable && this._account?.name
-      ? this.$.restAPI.setAccountName(this._account.name)
+      ? this.restApiService.setAccountName(this._account.name)
       : Promise.resolve();
   }
 
@@ -176,20 +168,20 @@
     // Note that we are intentionally not acting on this._username being the
     // empty string (which is falsy).
     return this._hasUsernameChange && this.usernameMutable && this._username
-      ? this.$.restAPI.setAccountUsername(this._username)
+      ? this.restApiService.setAccountUsername(this._username)
       : Promise.resolve();
   }
 
   _maybeSetDisplayName() {
     return this._hasDisplayNameChange &&
       this._account?.display_name !== undefined
-      ? this.$.restAPI.setAccountDisplayName(this._account.display_name)
+      ? this.restApiService.setAccountDisplayName(this._account.display_name)
       : Promise.resolve();
   }
 
   _maybeSetStatus() {
     return this._hasStatusChange && this._account?.status !== undefined
-      ? this.$.restAPI.setAccountStatus(this._account.status)
+      ? this.restApiService.setAccountStatus(this._account.status)
       : Promise.resolve();
   }
 
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts
index d69a279..fe4ef8e 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts
@@ -79,7 +79,7 @@
       </span>
     </section>
     <section id="nameSection">
-      <span class="title">Full name</span>
+      <label class="title" for="nameInput">Full name</label>
       <span hidden$="[[nameMutable]]" class="value">[[_account.name]]</span>
       <span hidden$="[[!nameMutable]]" class="value">
         <iron-input on-keydown="_handleKeydown" bind-value="{{_account.name}}">
@@ -94,7 +94,7 @@
       </span>
     </section>
     <section>
-      <span class="title">Display name</span>
+      <label class="title" for="displayNameInput">Display name</label>
       <span class="value">
         <iron-input
           on-keydown="_handleKeydown"
@@ -111,7 +111,7 @@
       </span>
     </section>
     <section>
-      <span class="title">Status (e.g. "Vacation")</span>
+      <label class="title" for="statusInput">Status (e.g. "Vacation")</label>
       <span class="value">
         <iron-input
           on-keydown="_handleKeydown"
@@ -128,5 +128,4 @@
       </span>
     </section>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.js b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.js
index d359ad2..c7cb44e 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-account-info.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-account-info');
 
@@ -46,13 +47,11 @@
     };
     config = {auth: {editable_account_fields: []}};
 
-    stub('gr-rest-api-interface', {
-      getAccount() { return Promise.resolve(account); },
-      getConfig() { return Promise.resolve(config); },
-      getPreferences() {
-        return Promise.resolve({time_format: 'HHMM_12'});
-      },
-    });
+    stubRestApi('getAccount').returns(Promise.resolve(account));
+    stubRestApi('getConfig').returns(Promise.resolve(config));
+    stubRestApi('getPreferences').returns(
+        Promise.resolve({time_format: 'HHMM_12'}));
+
     element = basicFixture.instantiate();
     // Allow the element to render.
     element.loadData().then(() => { flush(done); });
@@ -133,11 +132,12 @@
       element.set('_serverConfig',
           {auth: {editable_account_fields: ['FULL_NAME', 'USER_NAME']}});
 
-      nameStub = sinon.stub(element.$.restAPI, 'setAccountName').callsFake(
+      nameStub = stubRestApi('setAccountName').callsFake(
           name => Promise.resolve());
-      usernameStub = sinon.stub(element.$.restAPI, 'setAccountUsername')
+      usernameStub = stubRestApi('setAccountUsername')
           .callsFake(username => Promise.resolve());
-      statusStub = sinon.stub(element.$.restAPI, 'setAccountStatus').callsFake(
+      statusStub = stubRestApi(
+          'setAccountStatus').callsFake(
           status => Promise.resolve());
     });
 
@@ -217,11 +217,12 @@
       element.set('_serverConfig',
           {auth: {editable_account_fields: ['FULL_NAME']}});
 
-      nameStub = sinon.stub(element.$.restAPI, 'setAccountName').callsFake(
+      nameStub = stubRestApi('setAccountName').callsFake(
           name => Promise.resolve());
-      statusStub = sinon.stub(element.$.restAPI, 'setAccountStatus').callsFake(
+      statusStub = stubRestApi(
+          'setAccountStatus').callsFake(
           status => Promise.resolve());
-      sinon.stub(element.$.restAPI, 'setAccountUsername').callsFake(
+      stubRestApi('setAccountUsername').callsFake(
           username => Promise.resolve());
     });
 
@@ -261,7 +262,8 @@
       element.set('_serverConfig',
           {auth: {editable_account_fields: []}});
 
-      statusStub = sinon.stub(element.$.restAPI, 'setAccountStatus').callsFake(
+      statusStub = stubRestApi(
+          'setAccountStatus').callsFake(
           status => Promise.resolve());
     });
 
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.ts b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.ts
index 5523be1..da180c3 100644
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.ts
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.ts
@@ -17,21 +17,15 @@
 
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-agreements-list_html';
 import {getBaseUrl} from '../../../utils/url-util';
 import {customElement, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {ContributorAgreementInfo} from '../../../types/common';
+import {appContext} from '../../../services/app-context';
 
-export interface GrAgreementsList {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
 @customElement('gr-agreements-list')
 export class GrAgreementsList extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
@@ -43,6 +37,8 @@
   @property({type: Array})
   _agreements?: ContributorAgreementInfo[];
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
   attached() {
     super.attached();
@@ -50,7 +46,7 @@
   }
 
   loadData() {
-    return this.$.restAPI.getAccountAgreements().then(agreements => {
+    return this.restApiService.getAccountAgreements().then(agreements => {
       this._agreements = agreements;
     });
   }
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_html.ts b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_html.ts
index 194ca2b..1afac0d 100644
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_html.ts
@@ -52,5 +52,4 @@
     </table>
     <a href$="[[getUrl()]]">New Contributor Agreement</a>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.js b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.js
index 0c785aa..c8ce574 100644
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-agreements-list.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-agreements-list');
 
@@ -31,9 +32,7 @@
       name: 'Agreements 1',
     }];
 
-    stub('gr-rest-api-interface', {
-      getAccountAgreements() { return Promise.resolve(agreements); },
-    });
+    stubRestApi('getAccountAgreements').returns(Promise.resolve(agreements));
 
     element = basicFixture.instantiate();
 
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
index c0d6126..df927d6 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
@@ -16,7 +16,6 @@
  */
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-date-formatter/gr-date-formatter';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/shared-styles';
 import '../../../styles/gr-form-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
@@ -25,7 +24,9 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-table-editor_html';
 import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin';
-import {customElement, property} from '@polymer/decorators';
+import {customElement, property, observe} from '@polymer/decorators';
+import {ServerInfo} from '../../../types/common';
+import {appContext} from '../../../services/app-context';
 
 @customElement('gr-change-table-editor')
 class GrChangeTableEditor extends ChangeTableMixin(
@@ -41,6 +42,27 @@
   @property({type: Boolean, notify: true})
   showNumber?: boolean;
 
+  @property({type: Object})
+  serverConfig?: ServerInfo;
+
+  @property({type: Array})
+  defaultColumns?: string[];
+
+  flagsService = appContext.flagsService;
+
+  @observe('serverConfig')
+  _configChanged(config: ServerInfo) {
+    this.defaultColumns = this.getEnabledColumns(
+      this.columnNames,
+      config,
+      this.flagsService.enabledExperiments
+    );
+    if (!this.displayedColumns) return;
+    this.displayedColumns = this.displayedColumns.filter(column =>
+      this.isColumnEnabled(column, config, this.flagsService.enabledExperiments)
+    );
+  }
+
   /**
    * Get the list of enabled column names from whichever checkboxes are
    * checked (excluding the number checkbox).
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.ts
index 1233cf1..a05ec73 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.ts
@@ -48,12 +48,13 @@
       </thead>
       <tbody>
         <tr>
-          <td>Number</td>
+          <td><label for="numberCheckbox">Number</label></td>
           <td
             class="checkboxContainer"
             on-click="_handleCheckboxContainerClick"
           >
             <input
+              id="numberCheckbox"
               type="checkbox"
               name="number"
               on-click="_handleNumberCheckboxClick"
@@ -61,14 +62,15 @@
             />
           </td>
         </tr>
-        <template is="dom-repeat" items="[[columnNames]]">
+        <template is="dom-repeat" items="[[defaultColumns]]">
           <tr>
-            <td>[[item]]</td>
+            <td><label for$="[[item]]">[[item]]</label></td>
             <td
               class="checkboxContainer"
               on-click="_handleCheckboxContainerClick"
             >
               <input
+                id$="[[item]]"
                 type="checkbox"
                 name="[[item]]"
                 on-click="_handleTargetClick"
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.js b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.js
index 42085ff..db5c035 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.js
@@ -41,6 +41,9 @@
 
     element.set('displayedColumns', columns);
     element.showNumber = false;
+    element.serverConfig = {
+      change: {},
+    };
     flush();
   });
 
@@ -51,13 +54,25 @@
 
     // The `+ 1` is for the number column, which isn't included in the change
     // table behavior's list.
-    assert.equal(rows.length, element.columnNames.length + 1);
-    for (let i = 0; i < columns.length; i++) {
+    assert.equal(rows.length, element.defaultColumns.length + 1);
+    for (let i = 0; i < element.defaultColumns.length; i++) {
       tds = rows[i + 1].querySelectorAll('td');
-      assert.equal(tds[0].textContent, columns[i]);
+      assert.equal(tds[0].textContent, element.defaultColumns[i]);
     }
   });
 
+  test('disabled experiments are hidden', () => {
+    assert.isFalse(element.displayedColumns.includes('Assignee'));
+    element.set('displayedColumns', columns);
+    element.serverConfig = {
+      change: {
+        enable_assignee: true,
+      },
+    };
+    flush();
+    assert.isTrue(element.displayedColumns.includes('Assignee'));
+  });
+
   test('hide item', () => {
     const checkbox = element.shadowRoot
         .querySelector('table tr:nth-child(2) input');
@@ -80,6 +95,10 @@
       'Branch',
       'Updated',
     ]);
+    // trigger computation of enabled displayed columns
+    element.serverConfig = {
+      change: {},
+    };
     flush();
     const checkbox = element.shadowRoot
         .querySelector('table tr:nth-child(2) input');
@@ -97,12 +116,15 @@
   });
 
   test('_getDisplayedColumns', () => {
-    assert.deepEqual(element._getDisplayedColumns(), columns);
+    const enabledColumns = columns.filter(column => element.isColumnEnabled(
+        column, element.serverConfig, []
+    ));
+    assert.deepEqual(element._getDisplayedColumns(), enabledColumns);
     MockInteractions.tap(
         element.shadowRoot
-            .querySelector('.checkboxContainer input[name=Assignee]'));
+            .querySelector('.checkboxContainer input[name=Subject]'));
     assert.deepEqual(element._getDisplayedColumns(),
-        columns.filter(c => c !== 'Assignee'));
+        enabledColumns.filter(c => c !== 'Subject'));
   });
 
   test('_handleCheckboxContainerClick relays taps to checkboxes', () => {
@@ -140,12 +162,12 @@
 
   test('_handleTargetClick', () => {
     sinon.spy(element, '_handleTargetClick');
-    assert.include(element.displayedColumns, 'Assignee');
+    assert.include(element.displayedColumns, 'Subject');
     MockInteractions
         .tap(element.shadowRoot
-            .querySelector('.checkboxContainer input[name=Assignee]'));
+            .querySelector('.checkboxContainer input[name=Subject]'));
     assert.isTrue(element._handleTargetClick.calledOnce);
-    assert.notInclude(element.displayedColumns, 'Assignee');
+    assert.notInclude(element.displayedColumns, 'Subject');
   });
 });
 
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..9f7fadf 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
@@ -19,25 +19,19 @@
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-cla-view_html';
 import {getBaseUrl} from '../../../utils/url-util';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {customElement, property} from '@polymer/decorators';
 import {
   ServerInfo,
   GroupInfo,
   ContributorAgreementInfo,
 } from '../../../types/common';
-
-export interface GrClaView {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
+import {fireAlert, fireTitleChange} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -74,30 +68,26 @@
   @property({type: String})
   _agreementsUrl?: string;
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
   attached() {
     super.attached();
     this.loadData();
 
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'New Contributor Agreement'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, 'New Contributor Agreement');
   }
 
   loadData() {
     const promises = [];
     promises.push(
-      this.$.restAPI.getConfig(true).then(config => {
+      this.restApiService.getConfig(true).then(config => {
         this._serverConfig = config;
       })
     );
 
     promises.push(
-      this.$.restAPI.getAccountGroups().then(groups => {
+      this.restApiService.getAccountGroups().then(groups => {
         if (!groups) return;
         this._groups = groups.sort((a, b) =>
           (a.name || '').localeCompare(b.name || '')
@@ -106,7 +96,7 @@
     );
 
     promises.push(
-      this.$.restAPI
+      this.restApiService
         .getAccountAgreements()
         .then((agreements: ContributorAgreementInfo[] | undefined) => {
           this._signedAgreements = agreements || [];
@@ -143,7 +133,7 @@
     this._createToast('Agreement saving...');
 
     const name = this._agreementName;
-    return this.$.restAPI.saveAccountAgreement({name}).then(res => {
+    return this.restApiService.saveAccountAgreement({name}).then(res => {
       let message = 'Agreement failed to be submitted, please try again';
       if (res.status === 200) {
         message = 'Agreement has been successfully submitted.';
@@ -156,13 +146,7 @@
   }
 
   _createToast(message: string) {
-    this.dispatchEvent(
-      new CustomEvent('show-alert', {
-        detail: {message},
-        bubbles: true,
-        composed: true,
-      })
-    );
+    fireAlert(this, message);
   }
 
   _computeShowAgreementsClass(showAgreements: boolean) {
@@ -172,15 +156,13 @@
   _disableAgreements(
     item: ContributorAgreementInfo,
     groups: GroupInfo[],
-    signedAgreements: ContributorAgreementInfo[]
+    signedAgreements?: ContributorAgreementInfo[]
   ) {
     if (!groups) return false;
     for (const group of groups) {
       if (
-        (item &&
-          item.auto_verify_group &&
-          item.auto_verify_group.id === group.id) ||
-        signedAgreements.find(i => i.name === item.name)
+        item?.auto_verify_group?.id === group.id ||
+        signedAgreements?.find(i => i.name === item.name)
       ) {
         return true;
       }
@@ -191,7 +173,7 @@
   _hideAgreements(
     item: ContributorAgreementInfo,
     groups: GroupInfo[],
-    signedAgreements: ContributorAgreementInfo[]
+    signedAgreements?: ContributorAgreementInfo[]
   ) {
     return this._disableAgreements(item, groups, signedAgreements)
       ? ''
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts
index c461718..59203d3 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts
@@ -25,7 +25,7 @@
       margin-bottom: var(--spacing-m);
     }
     .agreementsUrl {
-      border: 1px solid #b0bdcc;
+      border: 1px solid var(--border-color);
       margin-bottom: var(--spacing-xl);
       margin-left: var(--spacing-xl);
       margin-right: var(--spacing-xl);
@@ -122,5 +122,4 @@
       </div>
     </div>
   </main>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.js b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.js
index aeacea4..7c11136 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-cla-view.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-cla-view');
 
@@ -105,11 +106,10 @@
   ];
 
   setup(done => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve(config); },
-      getAccountGroups() { return Promise.resolve(groups); },
-      getAccountAgreements() { return Promise.resolve(signedAgreements); },
-    });
+    stubRestApi('getConfig').returns(Promise.resolve(config));
+    stubRestApi('getAccountGroups').returns(Promise.resolve(groups));
+    stubRestApi('getAccountAgreements').returns(
+        Promise.resolve(signedAgreements));
     element = basicFixture.instantiate();
     element.loadData().then(() => { flush(done); });
   });
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
index d49cb5a..411d3aa 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
@@ -17,19 +17,17 @@
 import '@polymer/iron-input/iron-input';
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-select/gr-select';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-edit-preferences_html';
 import {customElement, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {EditPreferencesInfo} from '../../../types/common';
+import {appContext} from '../../../services/app-context';
 
 export interface GrEditPreferences {
   $: {
-    restAPI: RestApiService & Element;
     editSyntaxHighlighting: HTMLInputElement;
     showAutoCloseBrackets: HTMLInputElement;
     showIndentWithTabs: HTMLInputElement;
@@ -52,8 +50,10 @@
   @property({type: Object})
   editPrefs?: EditPreferencesInfo;
 
+  private readonly restApiService = appContext.restApiService;
+
   loadData() {
-    return this.$.restAPI.getEditPreferences().then(prefs => {
+    return this.restApiService.getEditPreferences().then(prefs => {
       this.editPrefs = prefs;
     });
   }
@@ -101,7 +101,7 @@
   save() {
     if (!this.editPrefs)
       return Promise.reject(new Error('Missing edit preferences'));
-    return this.$.restAPI.saveEditPreferences(this.editPrefs).then(() => {
+    return this.restApiService.saveEditPreferences(this.editPrefs).then(() => {
       this.hasUnsavedChanges = false;
     });
   }
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.ts b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.ts
index 47e4592..f6344f0 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.ts
@@ -25,7 +25,7 @@
   </style>
   <div id="editPreferences" class="gr-form-styles">
     <section>
-      <span class="title">Tab width</span>
+      <label for="editTabWidth" class="title">Tab width</label>
       <span class="value">
         <iron-input
           type="number"
@@ -37,6 +37,7 @@
         >
           <input
             is="iron-input"
+            id="editTabWidth"
             type="number"
             prevent-invalid-input=""
             allowed-pattern="[0-9]"
@@ -48,7 +49,7 @@
       </span>
     </section>
     <section>
-      <span class="title">Columns</span>
+      <label for="editColumns" class="title">Columns</label>
       <span class="value">
         <iron-input
           type="number"
@@ -59,6 +60,7 @@
           on-change="_handleEditPrefsChanged"
         >
           <input
+            id="editColumns"
             is="iron-input"
             type="number"
             prevent-invalid-input=""
@@ -71,7 +73,7 @@
       </span>
     </section>
     <section>
-      <span class="title">Indent unit</span>
+      <label for="indentUnit" class="title">Indent unit</label>
       <span class="value">
         <iron-input
           type="number"
@@ -83,6 +85,7 @@
         >
           <input
             is="iron-input"
+            id="indentUnit"
             type="number"
             prevent-invalid-input=""
             allowed-pattern="[0-9]"
@@ -94,7 +97,9 @@
       </span>
     </section>
     <section>
-      <span class="title">Syntax highlighting</span>
+      <label for="editSyntaxHighlighting" class="title"
+        >Syntax highlighting</label
+      >
       <span class="value">
         <input
           id="editSyntaxHighlighting"
@@ -105,7 +110,7 @@
       </span>
     </section>
     <section>
-      <span class="title">Show tabs</span>
+      <label for="editShowTabs" class="title">Show tabs</label>
       <span class="value">
         <input
           id="editShowTabs"
@@ -116,7 +121,7 @@
       </span>
     </section>
     <section>
-      <span class="title">Match brackets</span>
+      <label for="showMatchBrackets" class="title">Match brackets</label>
       <span class="value">
         <input
           id="showMatchBrackets"
@@ -127,7 +132,7 @@
       </span>
     </section>
     <section>
-      <span class="title">Line wrapping</span>
+      <label for="editShowLineWrapping" class="title">Line wrapping</label>
       <span class="value">
         <input
           id="editShowLineWrapping"
@@ -138,7 +143,7 @@
       </span>
     </section>
     <section>
-      <span class="title">Indent with tabs</span>
+      <label for="showIndentWithTabs" class="title">Indent with tabs</label>
       <span class="value">
         <input
           id="showIndentWithTabs"
@@ -149,7 +154,9 @@
       </span>
     </section>
     <section>
-      <span class="title">Auto close brackets</span>
+      <label for="showAutoCloseBrackets" class="title"
+        >Auto close brackets</label
+      >
       <span class="value">
         <input
           id="showAutoCloseBrackets"
@@ -160,5 +167,4 @@
       </span>
     </section>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.js b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.js
index b1b8b61..cffd1ae 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-edit-preferences.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-edit-preferences');
 
@@ -56,11 +57,7 @@
       theme: 'DEFAULT',
     };
 
-    stub('gr-rest-api-interface', {
-      getEditPreferences() {
-        return Promise.resolve(editPreferences);
-      },
-    });
+    stubRestApi('getEditPreferences').returns(Promise.resolve(editPreferences));
 
     element = basicFixture.instantiate();
 
@@ -92,7 +89,7 @@
   });
 
   test('save changes', () => {
-    sinon.stub(element.$.restAPI, 'saveEditPreferences')
+    stubRestApi('saveEditPreferences')
         .returns(Promise.resolve());
     const showTabsCheckbox = valueOf('Show tabs', 'editPreferences')
         .firstElementChild;
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
index fd10a16..9960d83 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
@@ -16,7 +16,6 @@
  */
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/shared-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
@@ -24,14 +23,8 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-email-editor_html';
 import {customElement, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {EmailInfo} from '../../../types/common';
-
-export interface GrEmailEditor {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
+import {appContext} from '../../../services/app-context';
 
 @customElement('gr-email-editor')
 export class GrEmailEditor extends GestureEventListeners(
@@ -53,8 +46,10 @@
   @property({type: String})
   _newPreferred: string | null = null;
 
+  readonly restApiService = appContext.restApiService;
+
   loadData() {
-    return this.$.restAPI.getAccountEmails().then(emails => {
+    return this.restApiService.getAccountEmails().then(emails => {
       this._emails = emails ?? [];
     });
   }
@@ -63,12 +58,12 @@
     const promises: Promise<unknown>[] = [];
 
     for (const emailObj of this._emailsToRemove) {
-      promises.push(this.$.restAPI.deleteAccountEmail(emailObj.email));
+      promises.push(this.restApiService.deleteAccountEmail(emailObj.email));
     }
 
     if (this._newPreferred) {
       promises.push(
-        this.$.restAPI.setPreferredAccountEmail(this._newPreferred)
+        this.restApiService.setPreferredAccountEmail(this._newPreferred)
       );
     }
 
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.ts
index 525fca6..0591e4b 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.ts
@@ -95,5 +95,4 @@
       </tbody>
     </table>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts
index 18ff95c..bc4a4d2 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-email-editor.js';
 import {GrEmailEditor} from './gr-email-editor';
+import {spyRestApi, stubRestApi} from '../../../test/test-utils';
 
 const basicFixture = fixtureFromElement('gr-email-editor');
 
@@ -31,11 +32,7 @@
       {email: 'email@three.com'},
     ];
 
-    stub('gr-rest-api-interface', {
-      getAccountEmails() {
-        return Promise.resolve(emails);
-      },
-    });
+    stubRestApi('getAccountEmails').returns(Promise.resolve(emails));
 
     element = basicFixture.instantiate();
 
@@ -113,12 +110,9 @@
     assert.equal(element._emailsToRemove[0].email, 'email@three.com');
   });
 
-  test('save changes', done => {
-    const deleteEmailStub = sinon.stub(element.$.restAPI, 'deleteAccountEmail');
-    const setPreferredStub = sinon.stub(
-      element.$.restAPI,
-      'setPreferredAccountEmail'
-    );
+  test('save changes', async () => {
+    const deleteEmailSpy = spyRestApi('deleteAccountEmail');
+    const setPreferredSpy = spyRestApi('setPreferredAccountEmail');
 
     const rows = element
       .shadowRoot!.querySelector('table')!
@@ -139,15 +133,10 @@
     assert.equal(element._emailsToRemove[0].email, 'email@one.com');
     assert.equal(element._emails.length, 2);
 
-    // Save the changes.
-    element.save().then(() => {
-      assert.equal(deleteEmailStub.callCount, 1);
-      assert.equal(deleteEmailStub.getCall(0).args[0], 'email@one.com');
-
-      assert.isTrue(setPreferredStub.called);
-      assert.equal(setPreferredStub.getCall(0).args[0], 'email@three.com');
-
-      done();
-    });
+    await element.save();
+    assert.equal(deleteEmailSpy.callCount, 1);
+    assert.equal(deleteEmailSpy.getCall(0).args[0], 'email@one.com');
+    assert.isTrue(setPreferredSpy.called);
+    assert.equal(setPreferredSpy.getCall(0).args[0], 'email@three.com');
   });
 });
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts
index 21e414b..0fd0ad9 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts
@@ -19,7 +19,6 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/shared-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
@@ -31,11 +30,10 @@
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {appContext} from '../../../services/app-context';
 
 export interface GrGpgEditor {
   $: {
-    restAPI: RestApiService & Element;
     viewKeyOverlay: GrOverlay;
     addButton: GrButton;
     newKey: IronAutogrowTextareaElement;
@@ -70,9 +68,11 @@
   @property({type: Array})
   _keysToRemove: GpgKeyInfo[] = [];
 
+  private readonly restApiService = appContext.restApiService;
+
   loadData() {
     this._keys = [];
-    return this.$.restAPI.getAccountGPGKeys().then(keys => {
+    return this.restApiService.getAccountGPGKeys().then(keys => {
       if (!keys) {
         return;
       }
@@ -86,7 +86,7 @@
 
   save() {
     const promises = this._keysToRemove.map(key =>
-      this.$.restAPI.deleteAccountGPGKey(key.id!)
+      this.restApiService.deleteAccountGPGKey(key.id!)
     );
 
     return Promise.all(promises).then(() => {
@@ -117,7 +117,7 @@
   _handleAddKey() {
     this.$.addButton.disabled = true;
     this.$.newKey.disabled = true;
-    return this.$.restAPI
+    return this.restApiService
       .addAccountGPGKey({add: [this._newKey.trim()]})
       .then(() => {
         this.$.newKey.disabled = false;
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.ts
index 432bc4f..69ac702 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.ts
@@ -133,5 +133,4 @@
       >
     </fieldset>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.js b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.js
index 2792176..8820748 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-gpg-editor.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-gpg-editor');
 
@@ -50,9 +51,7 @@
       },
     };
 
-    stub('gr-rest-api-interface', {
-      getAccountGPGKeys() { return Promise.resolve(keys); },
-    });
+    stubRestApi('getAccountGPGKeys').returns(Promise.resolve(keys));
 
     element = basicFixture.instantiate();
 
@@ -74,7 +73,7 @@
   test('remove key', done => {
     const lastKey = keys[Object.keys(keys)[1]];
 
-    const saveStub = sinon.stub(element.$.restAPI, 'deleteAccountGPGKey')
+    const saveStub = stubRestApi('deleteAccountGPGKey')
         .callsFake(() => Promise.resolve());
 
     assert.equal(element._keysToRemove.length, 0);
@@ -130,7 +129,8 @@
       },
     };
 
-    const addStub = sinon.stub(element.$.restAPI, 'addAccountGPGKey').callsFake(
+    const addStub = stubRestApi(
+        'addAccountGPGKey').callsFake(
         () => Promise.resolve(newKeyObject));
 
     element._newKey = newKeyString;
@@ -155,7 +155,8 @@
   test('add invalid key', done => {
     const newKeyString = 'not even close to valid';
 
-    const addStub = sinon.stub(element.$.restAPI, 'addAccountGPGKey').callsFake(
+    const addStub = stubRestApi(
+        'addAccountGPGKey').callsFake(
         () => Promise.reject(new Error('error')));
 
     element._newKey = newKeyString;
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.ts b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.ts
index d631c53..c69bebd 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.ts
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.ts
@@ -16,21 +16,14 @@
  */
 import '../../../styles/shared-styles';
 import '../../../styles/gr-form-styles';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-group-list_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {customElement, property} from '@polymer/decorators';
 import {GroupInfo, GroupId} from '../../../types/common';
-
-export interface GrGroupList {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
+import {appContext} from '../../../services/app-context';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -48,8 +41,10 @@
   @property({type: Array})
   _groups: GroupInfo[] = [];
 
+  private readonly restApiService = appContext.restApiService;
+
   loadData() {
-    return this.$.restAPI.getAccountGroups().then(groups => {
+    return this.restApiService.getAccountGroups().then(groups => {
       if (!groups) return;
       this._groups = groups.sort((a, b) =>
         (a.name || '').localeCompare(b.name || '')
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_html.ts b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_html.ts
index e52583d..0ae3490 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_html.ts
@@ -57,5 +57,4 @@
       </tbody>
     </table>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.js b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.js
index e19345a..e048103 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-group-list.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-group-list');
 
@@ -45,9 +46,7 @@
       name: 'Group 3',
     }];
 
-    stub('gr-rest-api-interface', {
-      getAccountGroups() { return Promise.resolve(groups); },
-    });
+    stubRestApi('getAccountGroups').returns(Promise.resolve(groups));
 
     element = basicFixture.instantiate();
 
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
index 02683e3..9bb4b00 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
@@ -18,7 +18,6 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/shared-styles';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
@@ -26,7 +25,7 @@
 import {htmlTemplate} from './gr-http-password_html';
 import {property, customElement} from '@polymer/decorators';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {appContext} from '../../../services/app-context';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -36,7 +35,6 @@
 
 export interface GrHttpPassword {
   $: {
-    restAPI: RestApiService & Element;
     generatedPasswordOverlay: GrOverlay;
   };
 }
@@ -58,6 +56,8 @@
   @property({type: String})
   _passwordUrl: string | null = null;
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
   attached() {
     super.attached();
@@ -68,7 +68,7 @@
     const promises = [];
 
     promises.push(
-      this.$.restAPI.getAccount().then(account => {
+      this.restApiService.getAccount().then(account => {
         if (account) {
           this._username = account.username;
         }
@@ -76,7 +76,7 @@
     );
 
     promises.push(
-      this.$.restAPI.getConfig().then(info => {
+      this.restApiService.getConfig().then(info => {
         if (info) {
           this._passwordUrl = info.auth.http_password_url || null;
         } else {
@@ -91,7 +91,7 @@
   _handleGenerateTap() {
     this._generatedPassword = 'Generating...';
     this.$.generatedPasswordOverlay.open();
-    this.$.restAPI.generateAccountHttpPassword().then(newPassword => {
+    this.restApiService.generateAccountHttpPassword().then(newPassword => {
       this._generatedPassword = newPassword;
     });
   }
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_html.ts b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_html.ts
index 41084b5..549fc93 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_html.ts
@@ -94,5 +94,4 @@
       >
     </div>
   </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.js b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.js
index 920ad48..e403b4f 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-http-password.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-http-password');
 
@@ -29,10 +30,8 @@
     account = {username: 'user name'};
     config = {auth: {}};
 
-    stub('gr-rest-api-interface', {
-      getAccount() { return Promise.resolve(account); },
-      getConfig() { return Promise.resolve(config); },
-    });
+    stubRestApi('getAccount').returns(Promise.resolve(account));
+    stubRestApi('getConfig').returns(Promise.resolve(config));
 
     element = basicFixture.instantiate();
     element.loadData().then(() => { flush(done); });
@@ -42,7 +41,7 @@
     const button = element.$.generateButton;
     const nextPassword = 'the new password';
     let generateResolve;
-    const generateStub = sinon.stub(element.$.restAPI,
+    const generateStub = stubRestApi(
         'generateAccountHttpPassword')
         .callsFake(() => new Promise(resolve => {
           generateResolve = resolve;
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
index 5f2b6a5..837332a 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
@@ -19,23 +19,21 @@
 import '../../admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-identities_html';
 import {getBaseUrl} from '../../../utils/url-util';
 import {customElement, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {AccountExternalIdInfo, ServerInfo} from '../../../types/common';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {PolymerDomRepeatEvent} from '../../../types/types';
+import {appContext} from '../../../services/app-context';
 
 const AUTH = ['OPENID', 'OAUTH'];
 
 export interface GrIdentities {
   $: {
-    restAPI: RestApiService & Element;
     overlay: GrOverlay;
   };
 }
@@ -63,8 +61,10 @@
   })
   _showLinkAnotherIdentity?: boolean;
 
+  private readonly restApiService = appContext.restApiService;
+
   loadData() {
-    return this.$.restAPI.getExternalIds().then(id => {
+    return this.restApiService.getExternalIds().then(id => {
       this._identities = id ?? [];
     });
   }
@@ -79,9 +79,11 @@
 
   _handleDeleteItemConfirm() {
     this.$.overlay.close();
-    return this.$.restAPI.deleteAccountIdentity([this._idName!]).then(() => {
-      this.loadData();
-    });
+    return this.restApiService
+      .deleteAccountIdentity([this._idName!])
+      .then(() => {
+        this.loadData();
+      });
   }
 
   _handleConfirmDialogCancel() {
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_html.ts b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_html.ts
index 1472103..34af7eb 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_html.ts
@@ -103,5 +103,4 @@
       item-type="id"
     ></gr-confirm-delete-item-dialog>
   </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.js b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.js
index 8af5bd0..867473f 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-identities.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-identities');
 
@@ -39,14 +40,12 @@
     },
   ];
 
-  setup(done => {
-    stub('gr-rest-api-interface', {
-      getExternalIds() { return Promise.resolve(ids); },
-    });
+  setup(async () => {
+    stubRestApi('getExternalIds').returns(Promise.resolve(ids));
 
     element = basicFixture.instantiate();
-
-    element.loadData().then(() => { flush(done); });
+    await element.loadData();
+    await flush();
   });
 
   test('renders', () => {
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
index 0498b35..805c9ca 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
@@ -17,7 +17,6 @@
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-date-formatter/gr-date-formatter';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/shared-styles';
 import '../../../styles/gr-form-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
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 0e73062..85e692d 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
@@ -17,7 +17,6 @@
 import '@polymer/iron-input/iron-input';
 import '../../../styles/gr-form-styles';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/shared-styles';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
@@ -25,12 +24,12 @@
 import {htmlTemplate} from './gr-registration-dialog_html';
 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';
+import {appContext} from '../../../services/app-context';
+import {fireEvent} from '../../../utils/event-util';
 
 export interface GrRegistrationDialog {
   $: {
-    restAPI: RestApiService & Element;
     name: HTMLInputElement;
     username: HTMLInputElement;
     email: HTMLSelectElement;
@@ -83,6 +82,8 @@
   })
   _usernameMutable = false;
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
   ready() {
     super.ready();
@@ -107,11 +108,11 @@
   loadData() {
     this._loading = true;
 
-    const loadAccount = this.$.restAPI.getAccount().then(account => {
+    const loadAccount = this.restApiService.getAccount().then(account => {
       this._account = {...this._account, ...account};
     });
 
-    const loadConfig = this.$.restAPI.getConfig().then(config => {
+    const loadConfig = this.restApiService.getConfig().then(config => {
       this._serverConfig = config;
     });
 
@@ -123,22 +124,19 @@
   _save() {
     this._saving = true;
     const promises = [
-      this.$.restAPI.setAccountName(this.$.name.value),
-      this.$.restAPI.setPreferredAccountEmail(this.$.email.value || ''),
+      this.restApiService.setAccountName(this.$.name.value),
+      this.restApiService.setPreferredAccountEmail(this.$.email.value || ''),
     ];
 
     if (this._usernameMutable) {
-      promises.push(this.$.restAPI.setAccountUsername(this.$.username.value));
+      promises.push(
+        this.restApiService.setAccountUsername(this.$.username.value)
+      );
     }
 
     return Promise.all(promises).then(() => {
       this._saving = false;
-      this.dispatchEvent(
-        new CustomEvent('account-detail-update', {
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireEvent(this, 'account-detail-update');
     });
   }
 
@@ -154,12 +152,7 @@
 
   close() {
     this._saving = true; // disable buttons indefinitely
-    this.dispatchEvent(
-      new CustomEvent('close', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'close');
   }
 
   _computeSaveDisabled(name?: string, email?: string, saving?: boolean) {
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.ts b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.ts
index 11fbbc9..a1d6a5c 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.ts
@@ -129,5 +129,4 @@
       >
     </footer>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.js b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.js
index 468ef57..1088585 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.js
@@ -16,6 +16,7 @@
  */
 import '../../../test/common-test-setup-karma.js';
 import './gr-registration-dialog.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-registration-dialog');
 
@@ -38,27 +39,21 @@
       ],
     };
 
-    stub('gr-rest-api-interface', {
-      getAccount() {
-        return Promise.resolve(account);
-      },
-      setAccountName(name) {
-        account.name = name;
-        return Promise.resolve();
-      },
-      setAccountUsername(username) {
-        account.username = username;
-        return Promise.resolve();
-      },
-      setPreferredAccountEmail(email) {
-        account.email = email;
-        return Promise.resolve();
-      },
-      getConfig() {
-        return Promise.resolve(
-            {auth: {editable_account_fields: ['USER_NAME']}});
-      },
+    stubRestApi('getAccount').returns(Promise.resolve(account));
+    stubRestApi('setAccountName').callsFake(name => {
+      account.name = name;
+      return Promise.resolve();
     });
+    stubRestApi('setAccountUsername').callsFake(username => {
+      account.username = username;
+      return Promise.resolve();
+    });
+    stubRestApi('setPreferredAccountEmail').callsFake(email => {
+      account.email = email;
+      return Promise.resolve();
+    });
+    stubRestApi('getConfig').returns(
+        Promise.resolve({auth: {editable_account_fields: ['USER_NAME']}}));
 
     element = basicFixture.instantiate();
 
@@ -66,10 +61,8 @@
   });
 
   teardown(() => {
-    for (const eventType in _listeners) {
-      if (_listeners.hasOwnProperty(eventType)) {
-        element.removeEventListener(eventType, _listeners[eventType]);
-      }
+    for (const [eventType, listeners] of Object.entries(_listeners)) {
+      element.removeEventListener(eventType, listeners);
     }
   });
 
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..809139d 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
@@ -30,7 +30,6 @@
 import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../shared/gr-diff-preferences/gr-diff-preferences';
 import '../../shared/gr-page-nav/gr-page-nav';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-select/gr-select';
 import '../gr-account-info/gr-account-info';
 import '../gr-agreements-list/gr-agreements-list';
@@ -57,7 +56,6 @@
 import {GrIdentities} from '../gr-identities/gr-identities';
 import {GrEditPreferences} from '../gr-edit-preferences/gr-edit-preferences';
 import {GrDiffPreferences} from '../../shared/gr-diff-preferences/gr-diff-preferences';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {
   PreferencesInput,
   ServerInfo,
@@ -65,9 +63,11 @@
 } from '../../../types/common';
 import {GrSshEditor} from '../gr-ssh-editor/gr-ssh-editor';
 import {GrGpgEditor} from '../gr-gpg-editor/gr-gpg-editor';
-import {GerritView} from '../../core/gr-navigation/gr-navigation';
 import {GrEmailEditor} from '../gr-email-editor/gr-email-editor';
 import {CustomKeyboardEvent} from '../../../types/events';
+import {fireAlert, fireTitleChange} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {GerritView} from '../../../services/router/router-model';
 
 const PREFS_SECTION_FIELDS: Array<keyof PreferencesInput> = [
   'changes_per_page',
@@ -101,7 +101,6 @@
 
 export interface GrSettingsView {
   $: {
-    restAPI: RestApiService & Element;
     accountInfo: GrAccountInfo;
     watchedProjectsEditor: GrWatchedProjectsEditor;
     groupList: GrGroupList;
@@ -148,9 +147,6 @@
   @property({type: Boolean})
   _accountInfoChanged?: boolean;
 
-  @property({type: Array})
-  _changeTableColumnsNotDisplayed?: string[];
-
   @property({type: Object})
   _localPrefs: PreferencesInput = {};
 
@@ -213,19 +209,15 @@
 
   public _testOnly_loadingPromise?: Promise<void>;
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
   attached() {
     super.attached();
     // Polymer 2: anchor tag won't work on shadow DOM
     // we need to manually calling scrollIntoView when hash changed
     this.listen(window, 'location-change', '_handleLocationChange');
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'Settings'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, 'Settings');
 
     this._isDark = !!window.localStorage.getItem('dark-theme');
 
@@ -239,20 +231,23 @@
     ];
 
     promises.push(
-      this.$.restAPI.getPreferences().then(prefs => {
+      this.restApiService.getPreferences().then(prefs => {
         if (!prefs) {
           throw new Error('getPreferences returned undefined');
         }
         this.prefs = prefs;
         this._showNumber = !!prefs.legacycid_in_change_table;
         this._copyPrefs(CopyPrefsDirection.PrefsToLocalPrefs);
-        this._cloneMenu(prefs.my);
-        this._cloneChangeTableColumns(prefs.change_table);
+        this._localMenu = this._cloneMenu(prefs.my);
+        this._localChangeTableColumns =
+          prefs.change_table.length === 0
+            ? this.columnNames
+            : this.renameProjectToRepoColumn(prefs.change_table);
       })
     );
 
     promises.push(
-      this.$.restAPI.getConfig().then(config => {
+      this.restApiService.getConfig().then(config => {
         this._serverConfig = config;
         const configPromises: Array<Promise<void>> = [];
 
@@ -269,7 +264,7 @@
         }
 
         configPromises.push(
-          getDocsBaseUrl(config, this.$.restAPI).then(baseUrl => {
+          getDocsBaseUrl(config, this.restApiService).then(baseUrl => {
             this._docsBaseUrl = baseUrl;
           })
         );
@@ -284,18 +279,14 @@
       this.params.emailToken
     ) {
       promises.push(
-        this.$.restAPI.confirmEmail(this.params.emailToken).then(message => {
-          if (message) {
-            this.dispatchEvent(
-              new CustomEvent('show-alert', {
-                detail: {message},
-                composed: true,
-                bubbles: true,
-              })
-            );
-          }
-          this.$.emailEditor.loadData();
-        })
+        this.restApiService
+          .confirmEmail(this.params.emailToken)
+          .then(message => {
+            if (message) {
+              fireAlert(this, message);
+            }
+            this.$.emailEditor.loadData();
+          })
       );
     } else {
       promises.push(this.$.emailEditor.loadData());
@@ -350,29 +341,10 @@
   }
 
   _cloneMenu(prefs: TopMenuItemInfo[]) {
-    const menu = [];
-    for (const item of prefs) {
-      menu.push({
-        name: item.name,
-        url: item.url,
-        target: item.target,
-      });
-    }
-    this._localMenu = menu;
-  }
-
-  _cloneChangeTableColumns(changeTable: string[]) {
-    let columns = this.getVisibleColumns(changeTable);
-
-    if (columns.length === 0) {
-      columns = this.columnNames;
-      this._changeTableColumnsNotDisplayed = [];
-    } else {
-      this._changeTableColumnsNotDisplayed = this.getComplementColumns(
-        changeTable
-      );
-    }
-    this._localChangeTableColumns = columns;
+    // eslint-disable-next-line @typescript-eslint/no-unused-vars
+    return prefs.map(({id, ...item}) => {
+      return item;
+    });
   }
 
   @observe('_localChangeTableColumns', '_showNumber')
@@ -438,7 +410,7 @@
   _handleSavePreferences() {
     this._copyPrefs(CopyPrefsDirection.LocalPrefsToPrefs);
 
-    return this.$.restAPI.savePreferences(this.prefs).then(() => {
+    return this.restApiService.savePreferences(this.prefs).then(() => {
       this._prefsChanged = false;
     });
   }
@@ -446,8 +418,7 @@
   _handleSaveChangeTable() {
     this.set('prefs.change_table', this._localChangeTableColumns);
     this.set('prefs.legacycid_in_change_table', this._showNumber);
-    this._cloneChangeTableColumns(this._localChangeTableColumns);
-    return this.$.restAPI.savePreferences(this.prefs).then(() => {
+    return this.restApiService.savePreferences(this.prefs).then(() => {
       this._changeTableChanged = false;
     });
   }
@@ -462,16 +433,15 @@
 
   _handleSaveMenu() {
     this.set('prefs.my', this._localMenu);
-    this._cloneMenu(this._localMenu);
-    return this.$.restAPI.savePreferences(this.prefs).then(() => {
+    return this.restApiService.savePreferences(this.prefs).then(() => {
       this._menuChanged = false;
     });
   }
 
   _handleResetMenuButton() {
-    return this.$.restAPI.getDefaultPreferences().then(data => {
+    return this.restApiService.getDefaultPreferences().then(data => {
       if (data?.my) {
-        this._cloneMenu(data.my);
+        this._localMenu = this._cloneMenu(data.my);
       }
     });
   }
@@ -508,7 +478,7 @@
     if (!this._isNewEmailValid(this._newEmail)) return;
 
     this._addingEmail = true;
-    this.$.restAPI.addAccountEmail(this._newEmail).then(response => {
+    this.restApiService.addAccountEmail(this._newEmail).then(response => {
       this._addingEmail = false;
 
       // If it was unsuccessful.
@@ -542,15 +512,7 @@
       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,
-      })
-    );
+    fireAlert(this, `Theme changed to ${this._isDark ? 'dark' : 'light'}.`);
   }
 
   _showHttpAuth(config?: ServerInfo) {
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
index 78f84b1..11372a1 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
@@ -33,7 +33,7 @@
     #email {
       margin-bottom: var(--spacing-l);
     }
-    main section.darkToggle {
+    .main section.darkToggle {
       display: block;
     }
     .filters p,
@@ -99,7 +99,7 @@
         </gr-endpoint-decorator>
       </ul>
     </gr-page-nav>
-    <main class="gr-form-styles">
+    <div class="main gr-form-styles">
       <h1 class="heading-1">User Settings</h1>
       <section class="darkToggle">
         <div class="toggle">
@@ -136,10 +136,12 @@
       </h2>
       <fieldset id="preferences">
         <section>
-          <span class="title">Changes per page</span>
+          <label class="title" for="changesPerPageSelect"
+            >Changes per page</label
+          >
           <span class="value">
             <gr-select bind-value="{{_localPrefs.changes_per_page}}">
-              <select>
+              <select id="changesPerPageSelect">
                 <option value="10">10 rows per page</option>
                 <option value="25">25 rows per page</option>
                 <option value="50">50 rows per page</option>
@@ -149,10 +151,12 @@
           </span>
         </section>
         <section>
-          <span class="title">Date/time format</span>
+          <label class="title" for="dateTimeFormatSelect"
+            >Date/time format</label
+          >
           <span class="value">
             <gr-select bind-value="{{_localPrefs.date_format}}">
-              <select>
+              <select id="dateTimeFormatSelect">
                 <option value="STD">Jun 3 ; Jun 3, 2016</option>
                 <option value="US">06/03 ; 06/03/16</option>
                 <option value="ISO">06-03 ; 2016-06-03</option>
@@ -160,7 +164,10 @@
                 <option value="UK">03/06 ; 03/06/2016</option>
               </select>
             </gr-select>
-            <gr-select bind-value="{{_localPrefs.time_format}}">
+            <gr-select
+              bind-value="{{_localPrefs.time_format}}"
+              aria-label="Time Format"
+            >
               <select>
                 <option value="HHMM_12">4:10 PM</option>
                 <option value="HHMM_24">16:10</option>
@@ -169,10 +176,12 @@
           </span>
         </section>
         <section>
-          <span class="title">Email notifications</span>
+          <label class="title" for="emailNotificationsSelect"
+            >Email notifications</label
+          >
           <span class="value">
             <gr-select bind-value="{{_localPrefs.email_strategy}}">
-              <select>
+              <select id="emailNotificationsSelect">
                 <option value="CC_ON_OWN_COMMENTS">Every comment</option>
                 <option value="ENABLED">Only comments left by others</option>
                 <option value="ATTENTION_SET_ONLY"
@@ -184,10 +193,10 @@
           </span>
         </section>
         <section hidden$="[[!_localPrefs.email_format]]">
-          <span class="title">Email format</span>
+          <label class="title" for="emailFormatSelect">Email format</label>
           <span class="value">
             <gr-select bind-value="{{_localPrefs.email_format}}">
-              <select>
+              <select id="emailFormatSelect">
                 <option value="HTML_PLAINTEXT">HTML and plaintext</option>
                 <option value="PLAINTEXT">Plaintext only</option>
               </select>
@@ -206,7 +215,9 @@
           </span>
         </section>
         <section>
-          <span class="title">Show Relative Dates In Changes Table</span>
+          <label class="title" for="relativeDateInChangeTable"
+            >Show Relative Dates In Changes Table</label
+          >
           <span class="value">
             <input
               id="relativeDateInChangeTable"
@@ -228,7 +239,9 @@
           </span>
         </section>
         <section>
-          <span class="title">Show size bars in file list</span>
+          <label for="showSizeBarsInFileList" class="title"
+            >Show size bars in file list</label
+          >
           <span class="value">
             <input
               id="showSizeBarsInFileList"
@@ -239,7 +252,9 @@
           </span>
         </section>
         <section>
-          <span class="title">Publish comments on push</span>
+          <label for="publishCommentsOnPush" class="title"
+            >Publish comments on push</label
+          >
           <span class="value">
             <input
               id="publishCommentsOnPush"
@@ -250,8 +265,8 @@
           </span>
         </section>
         <section>
-          <span class="title"
-            >Set new changes to "work in progress" by default</span
+          <label for="workInProgressByDefault" class="title"
+            >Set new changes to "work in progress" by default</label
           >
           <span class="value">
             <input
@@ -263,9 +278,9 @@
           </span>
         </section>
         <section>
-          <span class="title">
+          <label for="insertSignedOff" class="title">
             Insert Signed-off-by Footer For Inline Edit Changes
-          </span>
+          </label>
           <span class="value">
             <input
               id="insertSignedOff"
@@ -340,6 +355,7 @@
       <fieldset id="changeTableColumns">
         <gr-change-table-editor
           show-number="{{_showNumber}}"
+          server-config="[[_serverConfig]]"
           displayed-columns="{{_localChangeTableColumns}}"
         >
         </gr-change-table-editor>
@@ -550,7 +566,6 @@
         </table>
       </fieldset>
       <gr-endpoint-decorator name="settings-screen"> </gr-endpoint-decorator>
-    </main>
+    </div>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js
index 0535e15..98abb3fb 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js
@@ -19,7 +19,8 @@
 import {getComputedStyleValue} from '../../../utils/dom-util.js';
 import './gr-settings-view.js';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GerritView} from '../../core/gr-navigation/gr-navigation.js';
+import {GerritView} from '../../../services/router/router-model.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-settings-view');
 const blankFixture = fixtureFromElement('div');
@@ -51,7 +52,7 @@
   }
 
   function stubAddAccountEmail(statusCode) {
-    return sinon.stub(element.$.restAPI, 'addAccountEmail').callsFake(
+    return stubRestApi('addAccountEmail').callsFake(
         () => Promise.resolve({status: statusCode}));
   }
 
@@ -82,17 +83,10 @@
     };
     config = {auth: {editable_account_fields: []}};
 
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(true); },
-      getAccount() { return Promise.resolve(account); },
-      getPreferences() { return Promise.resolve(preferences); },
-      getWatchedProjects() {
-        return Promise.resolve([]);
-      },
-      getAccountEmails() { return Promise.resolve(); },
-      getConfig() { return Promise.resolve(config); },
-      getAccountGroups() { return Promise.resolve([]); },
-    });
+    stubRestApi('getAccount').returns(Promise.resolve(account));
+    stubRestApi('getPreferences').returns(Promise.resolve(preferences));
+    stubRestApi('getAccountEmails').returns(Promise.resolve());
+    stubRestApi('getConfig').returns(Promise.resolve(config));
     element = basicFixture.instantiate();
 
     // Allow the element to render.
@@ -179,13 +173,11 @@
     assert.isTrue(element._prefsChanged);
     assert.isFalse(element._menuChanged);
 
-    stub('gr-rest-api-interface', {
-      savePreferences(prefs) {
-        assert.equal(prefs.diff_view, 'SIDE_BY_SIDE');
-        assertMenusEqual(prefs.my, preferences.my);
-        assert.equal(prefs.publish_comments_on_push, true);
-        return Promise.resolve();
-      },
+    stubRestApi('savePreferences').callsFake(prefs => {
+      assert.equal(prefs.diff_view, 'SIDE_BY_SIDE');
+      assertMenusEqual(prefs.my, preferences.my);
+      assert.equal(prefs.publish_comments_on_push, true);
+      return Promise.resolve();
     });
 
     // Save the change.
@@ -204,11 +196,9 @@
     assert.isFalse(element._menuChanged);
     assert.isTrue(element._prefsChanged);
 
-    stub('gr-rest-api-interface', {
-      savePreferences(prefs) {
-        assert.equal(prefs.publish_comments_on_push, true);
-        return Promise.resolve();
-      },
+    stubRestApi('savePreferences').callsFake(prefs => {
+      assert.equal(prefs.publish_comments_on_push, true);
+      return Promise.resolve();
     });
 
     // Save the change.
@@ -228,11 +218,9 @@
     assert.isFalse(element._menuChanged);
     assert.isTrue(element._prefsChanged);
 
-    stub('gr-rest-api-interface', {
-      savePreferences(prefs) {
-        assert.equal(prefs.work_in_progress_by_default, true);
-        return Promise.resolve();
-      },
+    stubRestApi('savePreferences').callsFake(prefs => {
+      assert.equal(prefs.work_in_progress_by_default, true);
+      return Promise.resolve();
     });
 
     // Save the change.
@@ -263,11 +251,9 @@
     assert.isTrue(element._menuChanged);
     assert.isFalse(element._prefsChanged);
 
-    stub('gr-rest-api-interface', {
-      savePreferences(prefs) {
-        assertMenusEqual(prefs.my, element._localMenu);
-        return Promise.resolve();
-      },
+    stubRestApi('savePreferences').callsFake(prefs => {
+      assertMenusEqual(prefs.my, element._localMenu);
+      return Promise.resolve();
     });
 
     element._handleSaveMenu().then(() => {
@@ -351,9 +337,7 @@
     let newColumns = ['Owner', 'Project', 'Branch'];
     element._localChangeTableColumns = newColumns.slice(0);
     element._showNumber = false;
-    const cloneStub = sinon.stub(element, '_cloneChangeTableColumns');
     element._handleSaveChangeTable();
-    assert.isTrue(cloneStub.calledOnce);
     assert.deepEqual(element.prefs.change_table, newColumns);
     assert.isNotOk(element.prefs.legacycid_in_change_table);
 
@@ -361,7 +345,6 @@
     element._localChangeTableColumns = newColumns;
     element._showNumber = true;
     element._handleSaveChangeTable();
-    assert.isTrue(cloneStub.calledTwice);
     assert.deepEqual(element.prefs.change_table, newColumns);
     assert.isTrue(element.prefs.legacycid_in_change_table);
   });
@@ -375,9 +358,7 @@
       ],
     };
 
-    stub('gr-rest-api-interface', {
-      getDefaultPreferences() { return Promise.resolve(originalMenu); },
-    });
+    stubRestApi('getDefaultPreferences').returns(Promise.resolve(originalMenu));
 
     const updatedMenu = [
       {url: '/first/url', name: 'first name', target: '_blank'},
@@ -477,22 +458,19 @@
 
   suite('when email verification token is provided', () => {
     let resolveConfirm;
+    let confirmEmailStub;
 
     setup(() => {
       sinon.stub(element.$.emailEditor, 'loadData');
-      sinon.stub(
-          element.$.restAPI,
-          'confirmEmail')
-          .callsFake(
-              () => new Promise(
-                  resolve => { resolveConfirm = resolve; }));
+      confirmEmailStub = stubRestApi('confirmEmail').returns(
+          new Promise(resolve => { resolveConfirm = resolve; }));
       element.params = {view: GerritView.SETTINGS, emailToken: 'foo'};
       element.attached();
     });
 
     test('it is used to confirm email via rest API', () => {
-      assert.isTrue(element.$.restAPI.confirmEmail.calledOnce);
-      assert.isTrue(element.$.restAPI.confirmEmail.calledWith('foo'));
+      assert.isTrue(confirmEmailStub.calledOnce);
+      assert.isTrue(confirmEmailStub.calledWith('foo'));
     });
 
     test('emails are not loaded initially', () => {
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
index 507caef..b30b2cb 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
@@ -19,7 +19,6 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/shared-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
@@ -31,11 +30,10 @@
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {appContext} from '../../../services/app-context';
 
 export interface GrSshEditor {
   $: {
-    restAPI: RestApiService & Element;
     addButton: GrButton;
     newKey: IronAutogrowTextareaElement;
     viewKeyOverlay: GrOverlay;
@@ -70,8 +68,10 @@
   @property({type: Array})
   _keysToRemove: SshKeyInfo[] = [];
 
+  private readonly restApiService = appContext.restApiService;
+
   loadData() {
-    return this.$.restAPI.getAccountSSHKeys().then(keys => {
+    return this.restApiService.getAccountSSHKeys().then(keys => {
       if (!keys) return;
       this._keys = keys;
     });
@@ -79,7 +79,7 @@
 
   save() {
     const promises = this._keysToRemove.map(key =>
-      this.$.restAPI.deleteAccountSSHKey(`${key.seq}`)
+      this.restApiService.deleteAccountSSHKey(`${key.seq}`)
     );
     return Promise.all(promises).then(() => {
       this._keysToRemove = [];
@@ -113,7 +113,7 @@
   _handleAddKey() {
     this.$.addButton.disabled = true;
     this.$.newKey.disabled = true;
-    return this.$.restAPI
+    return this.restApiService
       .addAccountSSHKey(this._newKey.trim())
       .then(key => {
         this.$.newKey.disabled = false;
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.ts
index 96f770a..0bee1d3 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.ts
@@ -139,5 +139,4 @@
       >
     </fieldset>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.js b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.js
index 29ba692..8f99baa 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-ssh-editor.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-ssh-editor');
 
@@ -41,9 +42,7 @@
       valid: true,
     }];
 
-    stub('gr-rest-api-interface', {
-      getAccountSSHKeys() { return Promise.resolve(keys); },
-    });
+    stubRestApi('getAccountSSHKeys').returns(Promise.resolve(keys));
 
     element = basicFixture.instantiate();
 
@@ -65,7 +64,7 @@
   test('remove key', done => {
     const lastKey = keys[1];
 
-    const saveStub = sinon.stub(element.$.restAPI, 'deleteAccountSSHKey')
+    const saveStub = stubRestApi('deleteAccountSSHKey')
         .callsFake(() => Promise.resolve());
 
     assert.equal(element._keysToRemove.length, 0);
@@ -116,7 +115,8 @@
       valid: true,
     };
 
-    const addStub = sinon.stub(element.$.restAPI, 'addAccountSSHKey').callsFake(
+    const addStub = stubRestApi(
+        'addAccountSSHKey').callsFake(
         () => Promise.resolve(newKeyObject));
 
     element._newKey = newKeyString;
@@ -141,7 +141,8 @@
   test('add invalid key', done => {
     const newKeyString = 'not even close to valid';
 
-    const addStub = sinon.stub(element.$.restAPI, 'addAccountSSHKey').callsFake(
+    const addStub = stubRestApi(
+        'addAccountSSHKey').callsFake(
         () => Promise.reject(new Error('error')));
 
     element._newKey = newKeyString;
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
index 15f9c6b..a45e160 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
@@ -17,7 +17,6 @@
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
@@ -31,9 +30,9 @@
   GrAutocomplete,
   AutocompleteSuggestion,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {ProjectWatchInfo} from '../../../types/common';
+import {appContext} from '../../../services/app-context';
 
 const NOTIFICATION_TYPES = [
   {name: 'Changes', key: 'notify_new_changes'},
@@ -45,7 +44,6 @@
 
 export interface GrWatchedProjectsEditor {
   $: {
-    restAPI: RestApiService & Element;
     newFilter: HTMLInputElement;
     newProject: GrAutocomplete;
   };
@@ -70,21 +68,23 @@
   @property({type: Object})
   _query?: AutocompleteQuery;
 
+  private readonly restApiService = appContext.restApiService;
+
   constructor() {
     super();
     this._query = input => this._getProjectSuggestions(input);
   }
 
   loadData() {
-    return this.$.restAPI.getWatchedProjects().then(projs => {
+    return this.restApiService.getWatchedProjects().then(projs => {
       this._projects = projs;
     });
   }
 
   save() {
-    let deletePromise;
+    let deletePromise: Promise<Response | undefined>;
     if (this._projectsToRemove.length) {
-      deletePromise = this.$.restAPI.deleteWatchedProjects(
+      deletePromise = this.restApiService.deleteWatchedProjects(
         this._projectsToRemove
       );
     } else {
@@ -94,7 +94,7 @@
     return deletePromise
       .then(() => {
         if (this._projects) {
-          return this.$.restAPI.saveWatchedProjects(this._projects);
+          return this.restApiService.saveWatchedProjects(this._projects);
         } else {
           return Promise.resolve(undefined);
         }
@@ -119,16 +119,10 @@
   }
 
   _getProjectSuggestions(input: string) {
-    return this.$.restAPI.getSuggestedProjects(input).then(response => {
+    return this.restApiService.getSuggestedProjects(input).then(response => {
       const projects: AutocompleteSuggestion[] = [];
-      for (const key in response) {
-        if (!hasOwnProperty(response, key)) {
-          continue;
-        }
-        projects.push({
-          name: key,
-          value: response[key].id,
-        });
+      for (const [name, project] of Object.entries(response ?? {})) {
+        projects.push({name, value: project.id});
       }
       return projects;
     });
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts
index e3a90a2..edc8fb2 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts
@@ -120,5 +120,4 @@
       </tfoot>
     </table>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.js b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.js
index 2fd8900..aac8995 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-watched-projects-editor.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-watched-projects-editor');
 
@@ -44,21 +45,17 @@
       },
     ];
 
-    stub('gr-rest-api-interface', {
-      getSuggestedProjects(input) {
-        if (input.startsWith('th')) {
-          return Promise.resolve({'the project': {
-            id: 'the project',
-            state: 'ACTIVE',
-            web_links: [],
-          }});
-        } else {
-          return Promise.resolve({});
-        }
-      },
-      getWatchedProjects() {
-        return Promise.resolve(projects);
-      },
+    stubRestApi('getWatchedProjects').returns(Promise.resolve(projects));
+    stubRestApi('getSuggestedProjects').callsFake(input => {
+      if (input.startsWith('th')) {
+        return Promise.resolve({'the project': {
+          id: 'the project',
+          state: 'ACTIVE',
+          web_links: [],
+        }});
+      } else {
+        return Promise.resolve({});
+      }
     });
 
     element = basicFixture.instantiate();
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
index 9623442..8ff7a3a 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
@@ -17,7 +17,6 @@
 import '../gr-account-link/gr-account-link';
 import '../gr-button/gr-button';
 import '../gr-icons/gr-icons';
-import '../gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/shared-styles';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
@@ -25,13 +24,8 @@
 import {htmlTemplate} from './gr-account-chip_html';
 import {customElement, property} from '@polymer/decorators';
 import {AccountInfo, ChangeInfo} from '../../../types/common';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {appContext} from '../../../services/app-context';
 
-export interface GrAccountChip {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
 @customElement('gr-account-chip')
 export class GrAccountChip extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
@@ -94,6 +88,8 @@
   @property({type: Boolean})
   transparentBackground = false;
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
   ready() {
     super.ready();
@@ -118,7 +114,7 @@
   }
 
   _getHasAvatars() {
-    return this.$.restAPI
+    return this.restApiService
       .getConfig()
       .then(cfg =>
         Promise.resolve(!!(cfg && cfg.plugin && cfg.plugin.has_avatars))
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.ts b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.ts
index 991104f..4e6dd1a 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.ts
@@ -58,7 +58,6 @@
     gr-button.remove:focus {
       --gr-button: {
         @apply --gr-remove-button-style;
-        color: #333;
       }
     }
     gr-button.remove {
@@ -109,5 +108,4 @@
       <iron-icon icon="gr-icons:close"></iron-icon>
     </gr-button>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
index 925480f..2562a1a 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
@@ -16,7 +16,6 @@
  */
 import '../../../styles/shared-styles';
 import '../gr-autocomplete/gr-autocomplete';
-import '../gr-rest-api-interface/gr-rest-api-interface';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
index be23cb3..3f8fe6b 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
@@ -18,25 +18,21 @@
 import '../../../styles/shared-styles';
 import '../gr-avatar/gr-avatar';
 import '../gr-hovercard-account/gr-hovercard-account';
-import '../gr-rest-api-interface/gr-rest-api-interface';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-account-label_html';
 import {appContext} from '../../../services/app-context';
 import {getDisplayName} from '../../../utils/display-name-util';
-import {isServiceUser} from '../../../utils/account-util';
+import {isSelf, isServiceUser} from '../../../utils/account-util';
 import {customElement, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {ChangeInfo, AccountInfo, ServerInfo} from '../../../types/common';
 import {hasOwnProperty} from '../../../utils/common-util';
+import {fireEvent} from '../../../utils/event-util';
+import {isInvolved} from '../../../utils/change-util';
+import {ShowAlertEventDetail} from '../../../types/events';
 
-export interface GrAccountLabel {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
 @customElement('gr-account-label')
 export class GrAccountLabel extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
@@ -104,8 +100,16 @@
   @property({type: Object})
   _config?: ServerInfo;
 
+  @property({type: Boolean, reflectToAttribute: true})
+  selected = false;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  deselected = false;
+
   reporting: ReportingService;
 
+  private readonly restApiService = appContext.restApiService;
+
   constructor() {
     super();
     this.reporting = appContext.reportingService;
@@ -114,10 +118,10 @@
   /** @override */
   ready() {
     super.ready();
-    this.$.restAPI.getConfig().then(config => {
+    this.restApiService.getConfig().then(config => {
       this._config = config;
     });
-    this.$.restAPI.getAccount().then(account => {
+    this.restApiService.getAccount().then(account => {
       this._selfAccount = account;
     });
     this.addEventListener('attention-set-updated', () => {
@@ -204,12 +208,13 @@
   }
 
   _handleRemoveAttentionClick(e: MouseEvent) {
+    if (this.selected) return;
     e.preventDefault();
     e.stopPropagation();
     if (!this.account._account_id) return;
 
     this.dispatchEvent(
-      new CustomEvent('show-alert', {
+      new CustomEvent<ShowAlertEventDetail>('show-alert', {
         detail: {
           message: 'Saving attention set update ...',
           dismissOnNavigation: true,
@@ -232,16 +237,14 @@
       'attention-icon-remove',
       this._reportingDetails()
     );
-    this.$.restAPI
+    this.restApiService
       .removeFromAttentionSet(
         this.change._number,
         this.account._account_id,
         reason
       )
       .then(() => {
-        this.dispatchEvent(
-          new CustomEvent('hide-alert', {bubbles: true, composed: true})
-        );
+        fireEvent(this, 'hide-alert');
       });
   }
 
@@ -266,15 +269,43 @@
     };
   }
 
+  _computeAttentionButtonEnabled(
+    config: ServerInfo | undefined,
+    highlight: boolean,
+    account: AccountInfo,
+    change: ChangeInfo,
+    selfAccount: AccountInfo,
+    selected: boolean
+  ) {
+    if (selected) return true;
+    return (
+      this._hasUnforcedAttention(config, highlight, account, change) &&
+      (isInvolved(change, selfAccount) || isSelf(account, selfAccount))
+    );
+  }
+
   _computeAttentionIconTitle(
     config: ServerInfo | undefined,
     highlight: boolean,
     account: AccountInfo,
-    change: ChangeInfo
+    change: ChangeInfo,
+    selfAccount: AccountInfo,
+    force: boolean,
+    selected: boolean
   ) {
-    return this._hasUnforcedAttention(config, highlight, account, change)
+    const enabled = this._computeAttentionButtonEnabled(
+      config,
+      highlight,
+      account,
+      change,
+      selfAccount,
+      selected
+    );
+    return enabled
       ? 'Click to remove the user from the attention set'
-      : 'Disabled. Use "Modify" to make changes.';
+      : force
+      ? 'Disabled. Use "Modify" to make changes.'
+      : 'Disabled. Only involved users can change.';
   }
 }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
index 1d8b13e..e287f79 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
@@ -23,11 +23,8 @@
       vertical-align: top;
       position: relative;
       border-radius: var(--label-border-radius);
-      max-width: var(--account-max-length, 200px);
       box-sizing: border-box;
       white-space: nowrap;
-      overflow: hidden;
-      text-overflow: ellipsis;
       padding: 0 var(--account-label-padding-horizontal, 0);
     }
     /* If the first element is the avatar, then we cancel the left padding, so
@@ -85,6 +82,13 @@
       position: relative;
       top: 2px;
     }
+    .name {
+      display: inline-block;
+      vertical-align: top;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      max-width: var(--account-max-length, 180px);
+    }
     .hasAttention .name {
       font-weight: var(--font-weight-bold);
     }
@@ -109,9 +113,9 @@
         link=""
         aria-label="Remove user from attention set"
         on-click="_handleRemoveAttentionClick"
-        disabled="[[!_hasUnforcedAttention(_config, highlightAttention, account, change)]]"
-        has-tooltip="[[_hasUnforcedAttention(_config, highlightAttention, account, change)]]"
-        title="[[_computeAttentionIconTitle(_config, highlightAttention, account, change)]]"
+        disabled="[[!_computeAttentionButtonEnabled(_config, highlightAttention, account, change, _selfAccount, selected)]]"
+        has-tooltip="[[_computeAttentionButtonEnabled(_config, highlightAttention, account, change, _selfAccount, false)]]"
+        title="[[_computeAttentionIconTitle(_config, highlightAttention, account, change, _selfAccount, forceAttention, selected)]]"
         ><iron-icon class="attention" icon="gr-icons:attention"></iron-icon>
       </gr-button>
     </template>
@@ -132,5 +136,4 @@
       </template>
     </span>
   </span>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.js b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.js
index 2e54db2..e25bdea 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-account-label.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-account-label');
 
@@ -28,10 +29,8 @@
   }
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-      getLoggedIn() { return Promise.resolve(false); },
-    });
+    stubRestApi('getConfig').returns(Promise.resolve({}));
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
     element = basicFixture.instantiate();
     element._config = {
       user: {
@@ -82,16 +81,21 @@
   });
 
   suite('attention set', () => {
-    setup(() => {
+    setup(async () => {
+      const kermit = createAccount('kermit', 31);
       element.highlightAttention = true;
       element._config = {
         change: {enable_attention_set: true},
         user: {anonymous_coward_name: 'Anonymous Coward'},
       };
-      element._selfAccount = createAccount('kermit', 31);
+      element._selfAccount = kermit;
       element.account = createAccount('ernie', 42);
-      element.change = {attention_set: {42: {}}};
-      flush();
+      element.change = {
+        attention_set: {42: {}},
+        owner: kermit,
+        reviewers: {},
+      };
+      await flush();
     });
 
     test('show attention button', () => {
@@ -99,7 +103,8 @@
     });
 
     test('tap attention button', () => {
-      const apiStub = sinon.stub(element.$.restAPI, 'removeFromAttentionSet')
+      const apiStub = stubRestApi(
+          'removeFromAttentionSet')
           .callsFake(() => Promise.resolve());
       const button = element.shadowRoot.querySelector('#attentionButton');
       assert.ok(button);
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.js b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.js
index 554953e..af485c6 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-account-link.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-account-link');
 
@@ -25,9 +26,7 @@
   let element;
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-    });
+    stubRestApi('getConfig').returns(Promise.resolve({}));
     element = basicFixture.instantiate();
   });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
index c5e71fc..5cc1240 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
@@ -38,6 +38,7 @@
 import {GrAccountChip} from '../gr-account-chip/gr-account-chip';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {PaperInputElementExt} from '../../../types/types';
+import {fireAlert} from '../../../utils/event-util';
 
 const VALID_EMAIL_ALERT = 'Please input a valid email.';
 
@@ -256,13 +257,7 @@
         // Repopulate the input with what the user tried to enter and have
         // a toast tell them why they can't enter it.
         this.$.entry.setText(item);
-        this.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message: VALID_EMAIL_ALERT},
-            bubbles: true,
-            composed: true,
-          })
-        );
+        fireAlert(this, VALID_EMAIL_ALERT);
         return false;
       } else {
         const account = {email: item, _pendingAdd: true};
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.js b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.js
index 3acba5b..6430290 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-account-list.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-account-list');
 
@@ -60,9 +61,7 @@
     existingAccount1 = makeAccount();
     existingAccount2 = makeAccount();
 
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-    });
+    stubRestApi('getConfig').returns(Promise.resolve({}));
     element = basicFixture.instantiate();
     element.accounts = [existingAccount1, existingAccount2];
     suggestionsProvider = new MockSuggestionsProvider();
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
index e5806f0..a0fddcd 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
@@ -62,6 +62,9 @@
   @property({type: Boolean})
   _hideActionButton?: boolean;
 
+  @property({type: Boolean})
+  showDismiss = false;
+
   @property()
   _boundTransitionEndHandler?: (
     this: HTMLElement,
@@ -105,6 +108,10 @@
     }
   }
 
+  _handleDismissTap() {
+    this.hide();
+  }
+
   _hasZeroTransitionDuration() {
     const style = window.getComputedStyle(this);
     // transitionDuration is always given in seconds.
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.ts b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.ts
index d2aed40..b66a1dd 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.ts
@@ -74,6 +74,10 @@
       hidden$="[[_hideActionButton]]"
       on-click="_handleActionTap"
       >[[actionText]]</gr-button
+    ><template is="dom-if" if="[[showDismiss]]"
+      ><gr-button link="" class="action" on-click="_handleDismissTap"
+        >Dismiss</gr-button
+      ></template
     >
   </div>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
index 451bdfa..aff6d50 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
@@ -26,11 +26,12 @@
 import {IronFitMixin} from '../../../mixins/iron-fit-mixin/iron-fit-mixin';
 import {customElement, property, observe} from '@polymer/decorators';
 import {IronFitBehavior} from '@polymer/iron-fit-behavior/iron-fit-behavior';
+import {GrCursorManager} from '../gr-cursor-manager/gr-cursor-manager';
+import {fireEvent} from '../../../utils/event-util';
 
-// TODO(TS): Update once GrCursorManager is upated
 export interface GrAutocompleteDropdown {
   $: {
-    cursor: any;
+    cursor: GrCursorManager;
     suggestions: Element;
   };
 }
@@ -115,7 +116,7 @@
   }
 
   getCurrentText() {
-    return this.getCursorTarget().dataset['value'];
+    return this.getCursorTarget()?.dataset['value'] || '';
   }
 
   _handleUp(e: Event) {
@@ -204,12 +205,7 @@
   }
 
   _fireClose() {
-    this.dispatchEvent(
-      new CustomEvent('dropdown-closed', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'dropdown-closed');
   }
 
   getCursorTarget() {
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
index 668ea1b..f4eb053 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
@@ -30,6 +30,7 @@
 import {GrCursorManager} from '../gr-cursor-manager/gr-cursor-manager';
 import {PaperInputElementExt} from '../../../types/types';
 import {CustomKeyboardEvent} from '../../../types/events';
+import {fireEvent} from '../../../utils/event-util';
 
 const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+/g;
 const DEBOUNCE_WAIT_MS = 200;
@@ -55,9 +56,7 @@
 export interface AutocompleteSuggestion {
   name?: string;
   label?: string;
-  // TODO(TS): this value can be string or arbitrary object (in gr-create-repo-dialog)
-  // probably should limit it to string only as it seems not used
-  value?: any;
+  value?: string;
   text?: string;
 }
 
@@ -69,6 +68,8 @@
   AutocompleteCommitEventDetail
 >;
 
+const DEBOUNCER_UPDATE_SUGGESTIONS = 'update-suggestions';
+
 @customElement('gr-autocomplete')
 export class GrAutocomplete extends KeyboardShortcutMixin(
   GestureEventListeners(LegacyElementMixin(PolymerElement))
@@ -218,7 +219,7 @@
   detached() {
     super.detached();
     this.unlisten(document.body, 'click', '_handleBodyClick');
-    this.cancelDebouncer('update-suggestions');
+    this.cancelDebouncer(DEBOUNCER_UPDATE_SUGGESTIONS);
   }
 
   get focusStart() {
@@ -331,7 +332,7 @@
     if (noDebounce) {
       update();
     } else {
-      this.debounce('update-suggestions', update, DEBOUNCE_WAIT_MS);
+      this.debounce(DEBOUNCER_UPDATE_SUGGESTIONS, update, DEBOUNCE_WAIT_MS);
     }
   }
 
@@ -406,12 +407,7 @@
     if (this._suggestions.length) {
       this.set('_suggestions', []);
     } else {
-      this.dispatchEvent(
-        new CustomEvent('cancel', {
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireEvent(this, 'cancel');
     }
   }
 
@@ -450,7 +446,7 @@
   }
 
   _handleBodyClick(e: Event) {
-    const eventPath = e.path;
+    const eventPath = e.composedPath();
     if (!eventPath) return;
     for (let i = 0; i < eventPath.length; i++) {
       if (eventPath[i] === this) {
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
index 45bac9f..9d7e19b 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
@@ -15,8 +15,6 @@
  * limitations under the License.
  */
 import '../../../styles/shared-styles';
-import '../gr-js-api-interface/gr-js-api-interface';
-import '../gr-rest-api-interface/gr-rest-api-interface';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
@@ -25,13 +23,8 @@
 import {getPluginLoader} from '../gr-js-api-interface/gr-plugin-loader';
 import {customElement, property} from '@polymer/decorators';
 import {AccountInfo} from '../../../types/common';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {appContext} from '../../../services/app-context';
 
-export interface GrAvatar {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
 @customElement('gr-avatar')
 export class GrAvatar extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
@@ -49,6 +42,8 @@
   @property({type: Boolean})
   _hasAvatars = false;
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
   attached() {
     super.attached();
@@ -63,7 +58,7 @@
   }
 
   _getConfig() {
-    return this.$.restAPI.getConfig();
+    return this.restApiService.getConfig();
   }
 
   _accountChanged() {
@@ -94,6 +89,11 @@
       return '';
     }
     const avatars = account.avatars || [];
+    // if there is no avatar url in account, there is no avatar set on server,
+    // and request /avatar?s will be 404.
+    if (avatars.length === 0) {
+      return '';
+    }
     for (let i = 0; i < avatars.length; i++) {
       if (avatars[i].height === this.imageSize) {
         return avatars[i].url;
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts
index 0d8e78f..e55c8f1 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts
@@ -22,8 +22,7 @@
       display: inline-block;
       border-radius: 50%;
       background-size: cover;
-      background-color: var(--avatar-background-color, #f1f2f3);
+      background-color: var(--avatar-background-color, var(--gray-background));
     }
   </style>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.js b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.js
index 261d59c..0eef5a7 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.js
@@ -18,35 +18,54 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-avatar.js';
 import {getPluginLoader} from '../gr-js-api-interface/gr-plugin-loader.js';
+import {appContext} from '../../../services/app-context.js';
 
 const basicFixture = fixtureFromElement('gr-avatar');
 
 suite('gr-avatar tests', () => {
   let element;
+  const defaultAvatars = [
+    {
+      url: 'https://cdn.example.com/s12-p/photo.jpg',
+      height: 12,
+    },
+  ];
 
   setup(() => {
     element = basicFixture.instantiate();
   });
 
+  test('account without avatar', () => {
+    assert.equal(
+        element._buildAvatarURL({
+          _account_id: 123,
+        }),
+        '');
+  });
+
   test('methods', () => {
     assert.equal(
         element._buildAvatarURL({
           _account_id: 123,
+          avatars: defaultAvatars,
         }),
         '/accounts/123/avatar?s=16');
     assert.equal(
         element._buildAvatarURL({
           email: 'test@example.com',
+          avatars: defaultAvatars,
         }),
         '/accounts/test%40example.com/avatar?s=16');
     assert.equal(
         element._buildAvatarURL({
           name: 'John Doe',
+          avatars: defaultAvatars,
         }),
         '/accounts/John%20Doe/avatar?s=16');
     assert.equal(
         element._buildAvatarURL({
           username: 'John_Doe',
+          avatars: defaultAvatars,
         }),
         '/accounts/John_Doe/avatar?s=16');
     assert.equal(
@@ -96,7 +115,9 @@
       element.imageSize = 64;
       element.account = {
         _account_id: 123,
+        avatars: defaultAvatars,
       };
+      flush();
 
       assert.strictEqual(element.style.backgroundImage, '');
 
@@ -104,7 +125,7 @@
       getPluginLoader().loadPlugins([]);
 
       return Promise.all([
-        element.$.restAPI.getConfig(),
+        appContext.restApiService.getConfig(),
         getPluginLoader().awaitPluginsLoaded(),
       ]).then(() => {
         assert.isFalse(element.hasAttribute('hidden'));
@@ -134,7 +155,7 @@
       getPluginLoader().loadPlugins([]);
 
       return Promise.all([
-        element.$.restAPI.getConfig(),
+        appContext.restApiService.getConfig(),
         getPluginLoader().awaitPluginsLoaded(),
       ]).then(() => {
         assert.isTrue(element.hasAttribute('hidden'));
@@ -162,12 +183,13 @@
       element.imageSize = 64;
       element.account = {
         _account_id: 123,
+        avatars: defaultAvatars,
       };
       // Emulate plugins loaded.
       getPluginLoader().loadPlugins([]);
 
       return Promise.all([
-        element.$.restAPI.getConfig(),
+        appContext.restApiService.getConfig(),
         getPluginLoader().awaitPluginsLoaded(),
       ]).then(() => {
         assert.isTrue(element.hasAttribute('hidden'));
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
index 7a6ce2c..60b891e 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -61,7 +61,7 @@
   tooltip = '';
 
   // Note: don't assign a value to this, since constructor is called
-  // after created, the initial value maybe overriden by this
+  // after created, the initial value maybe overridden by this
   @property({type: String})
   _initialTabindex?: string;
 
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
index 44ec4f5..7cf9bb1 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-tooltip-content/gr-tooltip-content';
 import '../../../styles/shared-styles';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
@@ -36,9 +35,9 @@
   "It will not appear on dashboards unless you are CC'ed or assigned, " +
   'and email notifications will be silenced until the review is started.';
 
-const MERGE_CONFLICT_TOOLTIP =
+export const MERGE_CONFLICT_TOOLTIP =
   'This change has merge conflicts. ' +
-  'Download the patch and run "git rebase master". ' +
+  'Download the patch and run "git rebase". ' +
   'Upload a new patchset after resolving all merge conflicts.';
 
 const PRIVATE_TOOLTIP =
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.js b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.js
index 770a21c..16fc664 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-change-status.js';
+import {MERGE_CONFLICT_TOOLTIP} from './gr-change-status.js';
 
 const basicFixture = fixtureFromElement('gr-change-status');
 
@@ -24,10 +25,6 @@
     'It will not appear on dashboards unless you are CC\'ed or assigned, ' +
     'and email notifications will be silenced until the review is started.';
 
-const MERGE_CONFLICT_TOOLTIP = 'This change has merge conflicts. ' +
-  'Download the patch and run "git rebase master". ' +
-  'Upload a new patchset after resolving all merge conflicts.';
-
 const PRIVATE_TOOLTIP = 'This change is only visible to its owner and ' +
     'current reviewers (or anyone with "View Private Changes" permission).';
 
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..a5b7df7 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
@@ -15,9 +15,9 @@
  * limitations under the License.
  */
 import '../../../styles/shared-styles';
-import '../gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-storage/gr-storage';
 import '../gr-comment/gr-comment';
+import '../../diff/gr-diff/gr-diff';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
@@ -25,6 +25,7 @@
 import {htmlTemplate} from './gr-comment-thread_html';
 import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {
+  computeDiffFromContext,
   isDraft,
   isRobot,
   sortComments,
@@ -34,10 +35,14 @@
 } from '../../../utils/comment-util';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {appContext} from '../../../services/app-context';
-import {CommentSide, Side, SpecialFilePath} from '../../../constants/constants';
+import {
+  CommentSide,
+  createDefaultDiffPrefs,
+  Side,
+  SpecialFilePath,
+} from '../../../constants/constants';
 import {computeDisplayPath} from '../../../utils/path-list-util';
-import {customElement, observe, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {computed, customElement, observe, property} from '@polymer/decorators';
 import {
   CommentRange,
   ConfigInfo,
@@ -50,14 +55,20 @@
 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';
+import {GrButton} from '../gr-button/gr-button';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {RenderPreferences} from '../../../api/diff';
+import {check, assertIsDefined} from '../../../utils/common-util';
 
 const UNRESOLVED_EXPAND_COUNT = 5;
 const NEWLINE_PATTERN = /\n/g;
 
 export interface GrCommentThread {
   $: {
-    restAPI: RestApiService & Element;
-    storage: GrStorage;
+    replyBtn: GrButton;
+    quoteBtn: GrButton;
   };
 }
 
@@ -88,9 +99,9 @@
    * 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:
+   * diff-side:
    *     "left" or "right". These indicate which of the two diffed versions
    *     the comment relates to. In the case of unified diff, the left
    *     version is the one whose line number column is further to the left.
@@ -116,13 +127,13 @@
   keyEventTarget: HTMLElement = document.body;
 
   @property({type: String, reflectToAttribute: true})
-  commentSide?: Side;
+  diffSide?: Side;
 
   @property({type: String})
   patchNum?: PatchSetNum;
 
   @property({type: String})
-  path?: string;
+  path: string | undefined;
 
   @property({type: String, observer: '_projectNameChanged'})
   projectName?: RepoName;
@@ -146,8 +157,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;
@@ -164,6 +175,15 @@
   @property({type: Object})
   _projectConfig?: ConfigInfo;
 
+  @property({type: Object})
+  _prefs: DiffPreferencesInfo = createDefaultDiffPrefs();
+
+  @property({type: Object})
+  _renderPrefs: RenderPreferences = {
+    hide_left_side: true,
+    disable_context_control_buttons: true,
+  };
+
   @property({type: Boolean, reflectToAttribute: true})
   isRobotComment = false;
 
@@ -171,8 +191,14 @@
   showFileName = true;
 
   @property({type: Boolean})
+  showPortedComment = false;
+
+  @property({type: Boolean})
   showPatchset = true;
 
+  @property({type: Boolean})
+  showCommentContext = false;
+
   get keyBindings() {
     return {
       'e shift+e': '_handleEKey',
@@ -183,6 +209,14 @@
 
   flagsService = appContext.flagsService;
 
+  readonly storage = new GrStorage();
+
+  private isCommentContextExperimentEnabled = this.flagsService.isEnabled(
+    KnownExperimentId.COMMENT_CONTEXT
+  );
+
+  readonly restApiService = appContext.restApiService;
+
   /** @override */
   created() {
     super.created();
@@ -197,10 +231,35 @@
     this._getLoggedIn().then(loggedIn => {
       this._showActions = loggedIn;
     });
+    this.restApiService.getDiffPreferences().then(prefs => {
+      if (!prefs) return;
+      this._prefs = {
+        ...prefs,
+        show_file_comment_button: false,
+        // override explicitly so that diff doesn't take too much width
+        // compared to the context
+        line_wrapping: false,
+      };
+    });
     this._setInitialExpandedState();
   }
 
-  addOrEditDraft(lineNum?: number, rangeParam?: CommentRange) {
+  @computed('comments', 'path')
+  get _diff() {
+    if (this.comments === undefined || this.path === undefined) return;
+    if (!this.comments[0]?.context_lines?.length) return;
+    return computeDiffFromContext(this.comments[0].context_lines, this.path);
+  }
+
+  _shouldShowCommentContext(diff?: DiffInfo) {
+    return (
+      this.isCommentContextExperimentEnabled &&
+      this.showCommentContext &&
+      !!diff
+    );
+  }
+
+  addOrEditDraft(lineNum?: LineNumber, rangeParam?: CommentRange) {
     const lastComment = this.comments[this.comments.length - 1] || {};
     if (isDraft(lastComment)) {
       const commentEl = this._commentElWithDraftID(
@@ -223,7 +282,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;
@@ -239,20 +298,50 @@
     );
   }
 
-  _getDiffUrlForPath(path: string) {
-    if (!this.changeNum) throw new Error('changeNum is missing');
-    if (!this.projectName) throw new Error('projectName is missing');
+  _getDiffUrlForPath(
+    projectName?: RepoName,
+    changeNum?: NumericChangeId,
+    path?: string,
+    patchNum?: PatchSetNum
+  ) {
+    if (!changeNum || !projectName || !path) return undefined;
     if (isDraft(this.comments[0])) {
       return GerritNav.getUrlForDiffById(
-        this.changeNum,
-        this.projectName,
+        changeNum,
+        projectName,
         path,
-        this.patchNum
+        patchNum
       );
     }
     const id = this.comments[0].id;
     if (!id) throw new Error('A published comment is missing the id.');
-    return GerritNav.getUrlForComment(this.changeNum, this.projectName, id);
+    return GerritNav.getUrlForComment(changeNum, projectName, id);
+  }
+
+  getHighlightRange() {
+    const comment = this.comments?.[0];
+    if (!comment) return undefined;
+    if (comment.range) return comment.range;
+    if (comment.line) {
+      return {
+        start_line: comment.line,
+        start_character: 0,
+        end_line: comment.line,
+        end_character: 0,
+      };
+    }
+    return undefined;
+  }
+
+  _getUrlForViewDiff(comments: UIComment[]) {
+    assertIsDefined(this.changeNum, 'changeNum');
+    assertIsDefined(this.projectName, 'projectName');
+    check(comments.length > 0, 'comment not found');
+    return GerritNav.getUrlForComment(
+      this.changeNum,
+      this.projectName,
+      comments[0].id!
+    );
   }
 
   _getDiffUrlForComment(
@@ -266,13 +355,14 @@
       (this.comments.length && this.comments[0].side === 'PARENT') ||
       isDraft(this.comments[0])
     ) {
+      if (this.lineNum === 'LOST') throw new Error('invalid lineNum lost');
       return GerritNav.getUrlForDiffById(
         changeNum,
         projectName,
         path,
         patchNum,
         undefined,
-        this.lineNum
+        this.lineNum === FILE ? undefined : this.lineNum
       );
     }
     const id = this.comments[0].id;
@@ -284,6 +374,11 @@
     return path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
   }
 
+  _computeShowPortedComment(comment: UIComment) {
+    if (this._orderedComments.length === 0) return false;
+    return this.showPortedComment && comment.id === this._orderedComments[0].id;
+  }
+
   _computeDisplayPath(path: string) {
     const displayPath = computeDisplayPath(path);
     if (displayPath === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
@@ -293,20 +388,24 @@
   }
 
   _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 '';
   }
 
   _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
+    return this.restApiService.getLoggedIn();
+  }
+
+  _getUnresolvedLabel(unresolved?: boolean) {
+    return unresolved ? 'Unresolved' : 'Resolved';
   }
 
   @observe('comments.*')
@@ -397,15 +496,6 @@
     if (!id) throw new Error('Cannot reply to comment without id.');
     const reply = this._newReply(id, content, unresolved);
 
-    // If there is currently a comment in an editing state, add an attribute
-    // so that the gr-comment knows not to populate the draft text.
-    for (let i = 0; i < this.comments.length; i++) {
-      if (this.comments[i].__editing) {
-        reply.__otherEditing = true;
-        break;
-      }
-    }
-
     if (isEditing) {
       reply.__editing = true;
     }
@@ -490,13 +580,13 @@
     return d;
   }
 
-  _newDraft(lineNum?: number, range?: CommentRange) {
+  _newDraft(lineNum?: LineNumber, range?: CommentRange) {
     const d: UIDraft = {
       __draft: true,
       __draftID: Math.random().toString(36),
       __date: new Date(),
     };
-
+    if (lineNum === 'LOST') throw new Error('invalid lineNum lost');
     // For replies, always use same meta info as root.
     if (this.comments && this.comments.length >= 1) {
       const rootComment = this.comments[0];
@@ -504,8 +594,6 @@
       if (rootComment.patch_set !== undefined)
         d.patch_set = rootComment.patch_set;
       if (rootComment.side !== undefined) d.side = rootComment.side;
-      if (rootComment.__commentSide !== undefined)
-        d.__commentSide = rootComment.__commentSide;
       if (rootComment.line !== undefined) d.line = rootComment.line;
       if (rootComment.range !== undefined) d.range = rootComment.range;
       if (rootComment.parent !== undefined) d.parent = rootComment.parent;
@@ -514,9 +602,8 @@
       d.path = this.path;
       d.patch_set = this.patchNum;
       d.side = this._getSide(this.isOnParent);
-      d.__commentSide = this.commentSide;
 
-      if (lineNum) {
+      if (lineNum && lineNum !== FILE) {
         d.line = lineNum;
       }
       if (range) {
@@ -546,8 +633,8 @@
   }
 
   _handleCommentDiscard(e: Event) {
-    if (!this.changeNum) throw new Error('changeNum is missing');
-    if (!this.patchNum) throw new Error('patchNum is missing');
+    assertIsDefined(this.changeNum, 'changeNum');
+    assertIsDefined(this.patchNum, 'patchNum');
     const diffCommentEl = (dom(e) as EventApi).rootTarget as GrComment;
     const comment = diffCommentEl.comment;
     const idx = this._indexOf(comment, this.comments);
@@ -565,14 +652,14 @@
     // Check to see if there are any other open comments getting edited and
     // set the local storage value to its message value.
     for (const changeComment of this.comments) {
-      if (changeComment.__editing) {
+      if (isDraft(changeComment) && changeComment.__editing) {
         const commentLocation: StorageLocation = {
           changeNum: this.changeNum,
           patchNum: this.patchNum,
           path: changeComment.path,
           line: changeComment.line,
         };
-        this.$.storage.setDraftComment(
+        this.storage.setDraftComment(
           commentLocation,
           changeComment.message ?? ''
         );
@@ -635,7 +722,7 @@
     if (!name) {
       return;
     }
-    this.$.restAPI.getProjectConfig(name).then(config => {
+    this.restApiService.getProjectConfig(name).then(config => {
       this._projectConfig = config;
     });
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
index 4c15383..55408c0 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
@@ -23,6 +23,12 @@
       font-size: var(--font-size-normal);
       font-weight: var(--font-weight-normal);
       line-height: var(--line-height-normal);
+      /* Explicitly set the background color of the diff. We
+       * cannot use the diff content type ab because of the skip chunk preceding
+       * it, diff processor assumes the chunk of type skip/ab can be collapsed
+       * and hides our diff behind context control buttons.
+       *  */
+      --dark-add-highlight-color: var(--background-color-primary);
     }
     gr-button {
       margin-left: var(--spacing-m);
@@ -34,24 +40,29 @@
       margin-left: auto;
       padding: var(--spacing-s) var(--spacing-m);
     }
-    #container {
+    .comment-box {
+      width: 80ch;
+      max-width: 100%;
       background-color: var(--comment-background-color);
       color: var(--comment-text-color);
-      display: var(--gr-comment-thread-display, block);
-      margin: 0 var(--spacing-s) var(--spacing-s);
-      white-space: normal;
       box-shadow: var(--elevation-level-2);
       border-radius: var(--border-radius);
+    }
+    #container {
+      display: var(--gr-comment-thread-display, flex);
+      align-items: flex-start;
+      margin: 0 var(--spacing-s) var(--spacing-s);
+      white-space: normal;
       /** This is required for firefox to continue the inheritance */
       -webkit-user-select: inherit;
       -moz-user-select: inherit;
       -ms-user-select: inherit;
       user-select: inherit;
     }
-    #container.unresolved {
+    .comment-box.unresolved {
       background-color: var(--unresolved-comment-background-color);
     }
-    #container.robotComment {
+    .comment-box.robotComment {
       background-color: var(--robot-comment-background-color);
     }
     #commentInfoContainer {
@@ -75,7 +86,18 @@
     .fileName {
       padding: var(--spacing-m) var(--spacing-s) var(--spacing-m);
     }
+    .diff-container {
+      margin-left: var(--spacing-l);
+      border: 1px solid var(--border-color);
+    }
+    .view-diff-container {
+      text-align: end;
+    }
+    .view-diff-button {
+      margin: var(--spacing-m);
+    }
   </style>
+
   <template is="dom-if" if="[[showFilePath]]">
     <template is="dom-if" if="[[showFileName]]">
       <div class="fileName">
@@ -83,7 +105,9 @@
           <span> [[_computeDisplayPath(path)]] </span>
         </template>
         <template is="dom-if" if="[[!_isPatchsetLevelComment(path)]]">
-          <a href$="[[_getDiffUrlForPath(path)]]">
+          <a
+            href$="[[_getDiffUrlForPath(projectName, changeNum, path, patchNum)]]"
+          >
             [[_computeDisplayPath(path)]]
           </a>
         </template>
@@ -98,72 +122,91 @@
       </template>
     </div>
   </template>
-  <div
-    id="container"
-    class$="[[_computeHostClass(unresolved, isRobotComment)]]"
-  >
-    <template
-      id="commentList"
-      is="dom-repeat"
-      items="[[_orderedComments]]"
-      as="comment"
-    >
-      <gr-comment
-        comment="{{comment}}"
-        comments="{{comments}}"
-        robot-button-disabled="[[_shouldDisableAction(_showActions, _lastComment)]]"
-        change-num="[[changeNum]]"
-        patch-num="[[patchNum]]"
-        draft="[[_isDraft(comment)]]"
-        show-actions="[[_showActions]]"
-        show-patchset="[[showPatchset]]"
-        comment-side="[[comment.__commentSide]]"
-        side="[[comment.side]]"
-        project-config="[[_projectConfig]]"
-        on-create-fix-comment="_handleCommentFix"
-        on-comment-discard="_handleCommentDiscard"
-        on-comment-save="_handleCommentSavedOrDiscarded"
-      ></gr-comment>
-    </template>
-    <div
-      id="commentInfoContainer"
-      hidden$="[[_hideActions(_showActions, _lastComment)]]"
-    >
-      <span id="unresolvedLabel" hidden$="[[!unresolved]]">Unresolved</span>
-      <div id="actions">
-        <gr-button
-          id="replyBtn"
-          link=""
-          class="action reply"
-          on-click="_handleCommentReply"
-          >Reply</gr-button
-        >
-        <gr-button
-          id="quoteBtn"
-          link=""
-          class="action quote"
-          on-click="_handleCommentQuote"
-          >Quote</gr-button
-        >
-        <template is="dom-if" if="[[unresolved]]">
+  <div id="container">
+    <div class$="[[_computeHostClass(unresolved, isRobotComment)]] comment-box">
+      <template
+        id="commentList"
+        is="dom-repeat"
+        items="[[_orderedComments]]"
+        as="comment"
+      >
+        <gr-comment
+          comment="{{comment}}"
+          comments="{{comments}}"
+          robot-button-disabled="[[_shouldDisableAction(_showActions, _lastComment)]]"
+          change-num="[[changeNum]]"
+          project-name="[[projectName]]"
+          patch-num="[[patchNum]]"
+          draft="[[_isDraft(comment)]]"
+          show-actions="[[_showActions]]"
+          show-patchset="[[showPatchset]]"
+          show-ported-comment="[[_computeShowPortedComment(comment)]]"
+          side="[[comment.side]]"
+          project-config="[[_projectConfig]]"
+          on-create-fix-comment="_handleCommentFix"
+          on-comment-discard="_handleCommentDiscard"
+          on-comment-save="_handleCommentSavedOrDiscarded"
+        ></gr-comment>
+      </template>
+      <div
+        id="commentInfoContainer"
+        hidden$="[[_hideActions(_showActions, _lastComment)]]"
+      >
+        <span id="unresolvedLabel">[[_getUnresolvedLabel(unresolved)]]</span>
+        <div id="actions">
           <gr-button
-            id="ackBtn"
+            id="replyBtn"
             link=""
-            class="action ack"
-            on-click="_handleCommentAck"
-            >Ack</gr-button
+            class="action reply"
+            on-click="_handleCommentReply"
+            >Reply</gr-button
           >
           <gr-button
-            id="doneBtn"
+            id="quoteBtn"
             link=""
-            class="action done"
-            on-click="_handleCommentDone"
-            >Done</gr-button
+            class="action quote"
+            on-click="_handleCommentQuote"
+            >Quote</gr-button
           >
-        </template>
+          <template is="dom-if" if="[[unresolved]]">
+            <gr-button
+              id="ackBtn"
+              link=""
+              class="action ack"
+              on-click="_handleCommentAck"
+              >Ack</gr-button
+            >
+            <gr-button
+              id="doneBtn"
+              link=""
+              class="action done"
+              on-click="_handleCommentDone"
+              >Done</gr-button
+            >
+          </template>
+        </div>
       </div>
     </div>
+    <template is="dom-if" if="[[_shouldShowCommentContext(_diff)]]">
+      <div class="diff-container">
+        <gr-diff
+          id="diff"
+          change-num="[[changeNum]]"
+          diff="[[_diff]]"
+          path="[[path]]"
+          prefs="[[_prefs]]"
+          render-prefs="[[_renderPrefs]]"
+          highlight-range="[[getHighlightRange(comments)]]"
+        >
+        </gr-diff>
+        <div class="view-diff-container">
+          <a href="[[_getUrlForViewDiff(comments)]]">
+            <gr-button link class="view-diff-button" on-click="_handleViewDiff">
+              View Diff
+            </gr-button>
+          </a>
+        </div>
+      </div>
+    </template>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  <gr-storage id="storage"></gr-storage>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js
deleted file mode 100644
index 1833b73..0000000
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js
+++ /dev/null
@@ -1,879 +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 './gr-comment-thread.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {SpecialFilePath} from '../../../constants/constants.js';
-import {sortComments} from '../../../utils/comment-util.js';
-
-const basicFixture = fixtureFromElement('gr-comment-thread');
-
-const withCommentFixture = fixtureFromElement('gr-comment-thread');
-
-suite('gr-comment-thread tests', () => {
-  suite('basic test', () => {
-    let element;
-
-    setup(() => {
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(false); },
-      });
-
-      element = basicFixture.instantiate();
-      element.patchNum = '3';
-      element.changeNum = '1';
-      flush();
-    });
-
-    test('comments are sorted correctly', () => {
-      const comments = [
-        {
-          message: 'i like you, too',
-          in_reply_to: 'sallys_confession',
-          __date: new Date('2015-12-25'),
-        }, {
-          id: 'sallys_confession',
-          message: 'i like you, jack',
-          updated: '2015-12-24 15:00:20.396000000',
-        }, {
-          id: 'sally_to_dr_finklestein',
-          message: 'i’m running away',
-          updated: '2015-10-31 09:00:20.396000000',
-        }, {
-          id: 'sallys_defiance',
-          in_reply_to: 'sally_to_dr_finklestein',
-          message: 'i will poison you so i can get away',
-          updated: '2015-10-31 15:00:20.396000000',
-        }, {
-          id: 'dr_finklesteins_response',
-          in_reply_to: 'sally_to_dr_finklestein',
-          message: 'no i will pull a thread and your arm will fall off',
-          updated: '2015-10-31 11:00:20.396000000',
-        }, {
-          id: 'sallys_mission',
-          message: 'i have to find santa',
-          updated: '2015-12-24 15:00:20.396000000',
-        },
-      ];
-      const results = sortComments(comments);
-      assert.deepEqual(results, [
-        {
-          id: 'sally_to_dr_finklestein',
-          message: 'i’m running away',
-          updated: '2015-10-31 09:00:20.396000000',
-        }, {
-          id: 'dr_finklesteins_response',
-          in_reply_to: 'sally_to_dr_finklestein',
-          message: 'no i will pull a thread and your arm will fall off',
-          updated: '2015-10-31 11:00:20.396000000',
-        }, {
-          id: 'sallys_defiance',
-          in_reply_to: 'sally_to_dr_finklestein',
-          message: 'i will poison you so i can get away',
-          updated: '2015-10-31 15:00:20.396000000',
-        }, {
-          id: 'sallys_confession',
-          message: 'i like you, jack',
-          updated: '2015-12-24 15:00:20.396000000',
-        }, {
-          id: 'sallys_mission',
-          message: 'i have to find santa',
-          updated: '2015-12-24 15:00:20.396000000',
-        }, {
-          message: 'i like you, too',
-          in_reply_to: 'sallys_confession',
-          __date: new Date('2015-12-25'),
-        },
-      ]);
-    });
-
-    test('addOrEditDraft w/ edit draft', () => {
-      element.comments = [{
-        id: 'jacks_reply',
-        message: 'i like you, too',
-        in_reply_to: 'sallys_confession',
-        updated: '2015-12-25 15:00:20.396000000',
-        __draft: true,
-      }];
-      const commentElStub = sinon.stub(element, '_commentElWithDraftID')
-          .callsFake(() => { return {}; });
-      const addDraftStub = sinon.stub(element, 'addDraft');
-
-      element.addOrEditDraft(123);
-
-      assert.isTrue(commentElStub.called);
-      assert.isFalse(addDraftStub.called);
-    });
-
-    test('addOrEditDraft w/o edit draft', () => {
-      element.comments = [];
-      const commentElStub = sinon.stub(element, '_commentElWithDraftID')
-          .callsFake(() => { return {}; });
-      const addDraftStub = sinon.stub(element, 'addDraft');
-
-      element.addOrEditDraft(123);
-
-      assert.isFalse(commentElStub.called);
-      assert.isTrue(addDraftStub.called);
-    });
-
-    test('_shouldDisableAction', () => {
-      let showActions = true;
-      const lastComment = {};
-      assert.equal(
-          element._shouldDisableAction(showActions, lastComment), false);
-      showActions = false;
-      assert.equal(
-          element._shouldDisableAction(showActions, lastComment), true);
-      showActions = true;
-      lastComment.__draft = true;
-      assert.equal(
-          element._shouldDisableAction(showActions, lastComment), true);
-      const robotComment = {};
-      robotComment.robot_id = true;
-      assert.equal(
-          element._shouldDisableAction(showActions, robotComment), false);
-    });
-
-    test('_hideActions', () => {
-      let showActions = true;
-      const lastComment = {};
-      assert.equal(element._hideActions(showActions, lastComment), false);
-      showActions = false;
-      assert.equal(element._hideActions(showActions, lastComment), true);
-      showActions = true;
-      lastComment.__draft = true;
-      assert.equal(element._hideActions(showActions, lastComment), true);
-      const robotComment = {};
-      robotComment.robot_id = true;
-      assert.equal(element._hideActions(showActions, robotComment), true);
-    });
-
-    test('setting project name loads the project config', done => {
-      const projectName = 'foo/bar/baz';
-      const getProjectStub = sinon.stub(element.$.restAPI, 'getProjectConfig')
-          .returns(Promise.resolve({}));
-      element.projectName = projectName;
-      flush(() => {
-        assert.isTrue(getProjectStub.calledWithExactly(projectName));
-        done();
-      });
-    });
-
-    test('optionally show file path', () => {
-      // Path info doesn't exist when showFilePath is false. Because it's in a
-      // dom-if it is not yet in the dom.
-      assert.isNotOk(element.shadowRoot
-          .querySelector('.pathInfo'));
-
-      const commentStub = sinon.stub(GerritNav, 'getUrlForComment');
-      element.changeNum = 123;
-      element.projectName = 'test project';
-      element.path = 'path/to/file';
-      element.latestPatchNum = 10;
-      element.patchNum = 3;
-      element.lineNum = 5;
-      element.comments = [{id: 'comment_id'}];
-      element.showFilePath = true;
-      flush();
-      assert.isOk(element.shadowRoot
-          .querySelector('.pathInfo'));
-      assert.notEqual(getComputedStyle(element.shadowRoot
-          .querySelector('.pathInfo')).display,
-      'none');
-      assert.isTrue(commentStub.calledWithExactly(
-          element.changeNum, element.projectName, 'comment_id'));
-    });
-
-    test('_computeDisplayPath', () => {
-      let path = 'path/to/file';
-      assert.equal(element._computeDisplayPath(path), 'path/to/file');
-
-      element.lineNum = 5;
-      assert.equal(element._computeDisplayPath(path), 'path/to/file');
-
-      element.patchNum = '3';
-      path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
-      assert.equal(element._computeDisplayPath(path), 'Patchset');
-    });
-
-    test('_computeDisplayLine', () => {
-      element.lineNum = 5;
-      assert.equal(element._computeDisplayLine(), '#5');
-
-      element.path = SpecialFilePath.COMMIT_MESSAGE;
-      element.lineNum = 5;
-      assert.equal(element._computeDisplayLine(), '#5');
-
-      element.lineNum = undefined;
-      element.path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
-      assert.equal(element._computeDisplayLine(), '');
-    });
-  });
-});
-
-suite('comment action tests with unresolved thread', () => {
-  let element;
-
-  setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(false); },
-      saveDiffDraft() {
-        return Promise.resolve({
-          ok: true,
-          text() {
-            return Promise.resolve(')]}\'\n' +
-                JSON.stringify({
-                  id: '7afa4931_de3d65bd',
-                  path: '/path/to/file.txt',
-                  line: 5,
-                  in_reply_to: 'baf0414d_60047215',
-                  updated: '2015-12-21 02:01:10.850000000',
-                  message: 'Done',
-                }));
-          },
-        });
-      },
-      deleteDiffDraft() { return Promise.resolve({ok: true}); },
-    });
-    element = withCommentFixture.instantiate();
-    element.patchNum = '1';
-    element.changeNum = '1';
-    element.comments = [{
-      author: {
-        name: 'Mr. Peanutbutter',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      id: 'baf0414d_60047215',
-      line: 5,
-      message: 'is this a crossover episode!?',
-      updated: '2015-12-08 19:48:33.843000000',
-      path: '/path/to/file.txt',
-      unresolved: true,
-      patch_set: 3,
-      __commentSide: 'left',
-    }];
-    flush();
-  });
-
-  test('reply', () => {
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    const reportStub = sinon.stub(element.reporting,
-        'recordDraftInteraction');
-    assert.ok(commentEl);
-
-    const replyBtn = element.$.replyBtn;
-    MockInteractions.tap(replyBtn);
-    flush();
-
-    const drafts = element._orderedComments.filter(c => c.__draft == true);
-    assert.equal(drafts.length, 1);
-    assert.notOk(drafts[0].message, 'message should be empty');
-    assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-    assert.isTrue(reportStub.calledOnce);
-  });
-
-  test('quote reply', () => {
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    const reportStub = sinon.stub(element.reporting,
-        'recordDraftInteraction');
-    assert.ok(commentEl);
-
-    const quoteBtn = element.$.quoteBtn;
-    MockInteractions.tap(quoteBtn);
-    flush();
-
-    const drafts = element._orderedComments.filter(c => c.__draft == true);
-    assert.equal(drafts.length, 1);
-    assert.equal(drafts[0].message, '> is this a crossover episode!?\n\n');
-    assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-    assert.isTrue(reportStub.calledOnce);
-  });
-
-  test('quote reply multiline', () => {
-    const reportStub = sinon.stub(element.reporting,
-        'recordDraftInteraction');
-    element.comments = [{
-      author: {
-        name: 'Mr. Peanutbutter',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      id: 'baf0414d_60047215',
-      path: 'test',
-      line: 5,
-      message: 'is this a crossover episode!?\nIt might be!',
-      updated: '2015-12-08 19:48:33.843000000',
-    }];
-    flush();
-
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const quoteBtn = element.$.quoteBtn;
-    MockInteractions.tap(quoteBtn);
-    flush();
-
-    const drafts = element._orderedComments.filter(c => c.__draft == true);
-    assert.equal(drafts.length, 1);
-    assert.equal(drafts[0].message,
-        '> is this a crossover episode!?\n> It might be!\n\n');
-    assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-    assert.isTrue(reportStub.calledOnce);
-  });
-
-  test('ack', done => {
-    const reportStub = sinon.stub(element.reporting,
-        'recordDraftInteraction');
-    element.changeNum = '42';
-    element.patchNum = '1';
-
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const ackBtn = element.shadowRoot.querySelector('#ackBtn');
-    MockInteractions.tap(ackBtn);
-    flush(() => {
-      const drafts = element.comments.filter(c => c.__draft == true);
-      assert.equal(drafts.length, 1);
-      assert.equal(drafts[0].message, 'Ack');
-      assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-      assert.equal(drafts[0].unresolved, false);
-      assert.isTrue(reportStub.calledOnce);
-      done();
-    });
-  });
-
-  test('done', done => {
-    const reportStub = sinon.stub(element.reporting,
-        'recordDraftInteraction');
-    element.changeNum = '42';
-    element.patchNum = '1';
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const doneBtn = element.shadowRoot.querySelector('#doneBtn');
-    MockInteractions.tap(doneBtn);
-    flush(() => {
-      const drafts = element.comments.filter(c => c.__draft == true);
-      assert.equal(drafts.length, 1);
-      assert.equal(drafts[0].message, 'Done');
-      assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-      assert.isFalse(drafts[0].unresolved);
-      assert.isTrue(reportStub.calledOnce);
-      done();
-    });
-  });
-
-  test('save', done => {
-    element.changeNum = '42';
-    element.patchNum = '1';
-    element.path = '/path/to/file.txt';
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const saveOrDiscardStub = sinon.stub();
-    element.addEventListener('thread-changed', saveOrDiscardStub);
-    element.shadowRoot
-        .querySelector('gr-comment')._fireSave();
-
-    flush(() => {
-      assert.isTrue(saveOrDiscardStub.called);
-      assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
-          'baf0414d_60047215');
-      assert.equal(element.rootId, 'baf0414d_60047215');
-      assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
-          '/path/to/file.txt');
-      done();
-    });
-  });
-
-  test('please fix', done => {
-    element.changeNum = '42';
-    element.patchNum = '1';
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    assert.ok(commentEl);
-    commentEl.addEventListener('create-fix-comment', () => {
-      const drafts = element._orderedComments.filter(c => c.__draft == true);
-      assert.equal(drafts.length, 1);
-      assert.equal(
-          drafts[0].message, '> is this a crossover episode!?\n\nPlease fix.');
-      assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-      assert.isTrue(drafts[0].unresolved);
-      done();
-    });
-    commentEl.dispatchEvent(
-        new CustomEvent('create-fix-comment', {
-          detail: {comment: commentEl.comment},
-          composed: true, bubbles: false,
-        }));
-  });
-
-  test('discard', done => {
-    element.changeNum = '42';
-    element.patchNum = '1';
-    element.path = '/path/to/file.txt';
-    element.push('comments', element._newReply(
-        element.comments[0].id,
-        element.comments[0].path,
-        'it’s pronouced jiff, not giff'));
-    flush();
-
-    const saveOrDiscardStub = sinon.stub();
-    element.addEventListener('thread-changed', saveOrDiscardStub);
-    const draftEl =
-        element.root.querySelectorAll('gr-comment')[1];
-    assert.ok(draftEl);
-    draftEl.addEventListener('comment-discard', () => {
-      const drafts = element.comments.filter(c => c.__draft == true);
-      assert.equal(drafts.length, 0);
-      assert.isTrue(saveOrDiscardStub.called);
-      assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
-          element.rootId);
-      assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
-          element.path);
-      done();
-    });
-    draftEl.dispatchEvent(
-        new CustomEvent('comment-discard', {
-          detail: {comment: draftEl.comment},
-          composed: true, bubbles: false,
-        }));
-  });
-
-  test('discard with a single comment still fires event with previous rootId',
-      done => {
-        element.changeNum = '42';
-        element.patchNum = '1';
-        element.path = '/path/to/file.txt';
-        element.comments = [];
-        element.addOrEditDraft('1');
-        flush();
-        const rootId = element.rootId;
-        assert.isOk(rootId);
-
-        const saveOrDiscardStub = sinon.stub();
-        element.addEventListener('thread-changed', saveOrDiscardStub);
-        const draftEl =
-        element.root.querySelectorAll('gr-comment')[0];
-        assert.ok(draftEl);
-        draftEl.addEventListener('comment-discard', () => {
-          assert.equal(element.comments.length, 0);
-          assert.isTrue(saveOrDiscardStub.called);
-          assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
-              rootId);
-          assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
-              element.path);
-          done();
-        });
-        draftEl.dispatchEvent(
-            new CustomEvent('comment-discard', {
-              detail: {comment: draftEl.comment},
-              composed: true, bubbles: false,
-            }));
-      });
-
-  test('first editing comment does not add __otherEditing attribute', () => {
-    element.comments = [{
-      author: {
-        name: 'Mr. Peanutbutter',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      id: 'baf0414d_60047215',
-      line: 5,
-      path: 'test',
-      message: 'is this a crossover episode!?',
-      updated: '2015-12-08 19:48:33.843000000',
-      __draft: true,
-    }];
-
-    const replyBtn = element.$.replyBtn;
-    MockInteractions.tap(replyBtn);
-    flush();
-
-    const editing = element._orderedComments.filter(c => c.__editing == true);
-    assert.equal(editing.length, 1);
-    assert.equal(!!editing[0].__otherEditing, false);
-  });
-
-  test('When not editing other comments, local storage not set' +
-      ' after discard', done => {
-    element.changeNum = '42';
-    element.patchNum = '1';
-    element.comments = [{
-      author: {
-        name: 'Mr. Peanutbutter',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      id: 'baf0414d_60047215',
-      path: 'test',
-      line: 5,
-      message: 'is this a crossover episode!?',
-      updated: '2015-12-08 19:48:31.843000000',
-    },
-    {
-      author: {
-        name: 'Mr. Peanutbutter',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      __draftID: '1',
-      in_reply_to: 'baf0414d_60047215',
-      path: 'test',
-      line: 5,
-      message: 'yes',
-      updated: '2015-12-08 19:48:32.843000000',
-      __draft: true,
-      __editing: true,
-    },
-    {
-      author: {
-        name: 'Mr. Peanutbutter',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      __draftID: '2',
-      in_reply_to: 'baf0414d_60047215',
-      path: 'test',
-      line: 5,
-      message: 'no',
-      updated: '2015-12-08 19:48:33.843000000',
-      __draft: true,
-    }];
-    const storageStub = sinon.stub(element.$.storage, 'setDraftComment');
-    flush();
-
-    const draftEl =
-    element.root.querySelectorAll('gr-comment')[1];
-    assert.ok(draftEl);
-    draftEl.addEventListener('comment-discard', () => {
-      assert.isFalse(storageStub.called);
-      storageStub.restore();
-      done();
-    });
-    draftEl.dispatchEvent(
-        new CustomEvent('comment-discard', {
-          detail: {comment: draftEl.comment},
-          composed: true, bubbles: false,
-        }));
-  });
-
-  test('comment-update', () => {
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    const updatedComment = {
-      id: element.comments[0].id,
-      foo: 'bar',
-    };
-    commentEl.dispatchEvent(
-        new CustomEvent('comment-update', {
-          detail: {comment: updatedComment},
-          composed: true, bubbles: true,
-        }));
-    assert.strictEqual(element.comments[0], updatedComment);
-  });
-
-  suite('jack and sally comment data test consolidation', () => {
-    setup(() => {
-      element.comments = [
-        {
-          id: 'jacks_reply',
-          message: 'i like you, too',
-          in_reply_to: 'sallys_confession',
-          updated: '2015-12-25 15:00:20.396000000',
-          unresolved: false,
-        }, {
-          id: 'sallys_confession',
-          in_reply_to: 'nonexistent_comment',
-          message: 'i like you, jack',
-          updated: '2015-12-24 15:00:20.396000000',
-        }, {
-          id: 'sally_to_dr_finklestein',
-          in_reply_to: 'nonexistent_comment',
-          message: 'i’m running away',
-          updated: '2015-10-31 09:00:20.396000000',
-        }, {
-          id: 'sallys_defiance',
-          message: 'i will poison you so i can get away',
-          updated: '2015-10-31 15:00:20.396000000',
-        }];
-    });
-
-    test('orphan replies', () => {
-      assert.equal(4, element._orderedComments.length);
-    });
-
-    test('keyboard shortcuts', () => {
-      const expandCollapseStub =
-          sinon.stub(element, '_expandCollapseComments');
-      MockInteractions.pressAndReleaseKeyOn(element, 69, null, 'e');
-      assert.isTrue(expandCollapseStub.lastCall.calledWith(false));
-
-      MockInteractions.pressAndReleaseKeyOn(element, 69, 'shift', 'e');
-      assert.isTrue(expandCollapseStub.lastCall.calledWith(true));
-    });
-
-    test('comment in_reply_to is either null or most recent comment', () => {
-      element._createReplyComment('dummy', true);
-      flush();
-      assert.equal(element._orderedComments.length, 5);
-      assert.equal(element._orderedComments[4].in_reply_to, 'jacks_reply');
-    });
-
-    test('resolvable comments', () => {
-      assert.isFalse(element.unresolved);
-      element._createReplyComment('dummy', true, true);
-      flush();
-      assert.isTrue(element.unresolved);
-    });
-
-    test('_setInitialExpandedState with unresolved', () => {
-      element.unresolved = true;
-      element._setInitialExpandedState();
-      for (let i = 0; i < element.comments.length; i++) {
-        assert.isFalse(element.comments[i].collapsed);
-      }
-    });
-
-    test('_setInitialExpandedState without unresolved', () => {
-      element.unresolved = false;
-      element._setInitialExpandedState();
-      for (let i = 0; i < element.comments.length; i++) {
-        assert.isTrue(element.comments[i].collapsed);
-      }
-    });
-
-    test('_setInitialExpandedState with robot_ids', () => {
-      for (let i = 0; i < element.comments.length; i++) {
-        element.comments[i].robot_id = 123;
-      }
-      element._setInitialExpandedState();
-      for (let i = 0; i < element.comments.length; i++) {
-        assert.isFalse(element.comments[i].collapsed);
-      }
-    });
-
-    test('_setInitialExpandedState with collapsed state', () => {
-      element.comments[0].collapsed = false;
-      element.unresolved = false;
-      element._setInitialExpandedState();
-      assert.isFalse(element.comments[0].collapsed);
-      for (let i = 1; i < element.comments.length; i++) {
-        assert.isTrue(element.comments[i].collapsed);
-      }
-    });
-  });
-
-  test('_computeHostClass', () => {
-    assert.equal(element._computeHostClass(true), 'unresolved');
-    assert.equal(element._computeHostClass(false), '');
-  });
-
-  test('addDraft sets unresolved state correctly', () => {
-    let unresolved = true;
-    element.comments = [];
-    element.addDraft(null, null, unresolved);
-    assert.equal(element.comments[0].unresolved, true);
-
-    unresolved = false; // comment should get added as actually resolved.
-    element.comments = [];
-    element.addDraft(null, null, unresolved);
-    assert.equal(element.comments[0].unresolved, false);
-
-    element.comments = [];
-    element.addDraft();
-    assert.equal(element.comments[0].unresolved, true);
-  });
-
-  test('_newDraft with root', () => {
-    const draft = element._newDraft();
-    assert.equal(draft.__commentSide, 'left');
-    assert.equal(draft.patch_set, 3);
-  });
-
-  test('_newDraft with no root', () => {
-    element.comments = [];
-    element.commentSide = 'right';
-    element.patchNum = 2;
-    const draft = element._newDraft();
-    assert.equal(draft.__commentSide, 'right');
-    assert.equal(draft.patch_set, 2);
-  });
-
-  test('new comment gets created', () => {
-    element.comments = [];
-    element.addOrEditDraft(1);
-    assert.equal(element.comments.length, 1);
-    // Mock a submitted comment.
-    element.comments[0].id = element.comments[0].__draftID;
-    element.comments[0].__draft = false;
-    element.addOrEditDraft(1);
-    assert.equal(element.comments.length, 2);
-  });
-
-  test('unresolved label', () => {
-    element.unresolved = false;
-    assert.isTrue(element.$.unresolvedLabel.hasAttribute('hidden'));
-    element.unresolved = true;
-    assert.isFalse(element.$.unresolvedLabel.hasAttribute('hidden'));
-  });
-
-  test('draft comments are at the end of orderedComments', () => {
-    element.comments = [{
-      author: {
-        name: 'Mr. Peanutbutter',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      id: 2,
-      line: 5,
-      message: 'Earlier draft',
-      updated: '2015-12-08 19:48:33.843000000',
-      __draft: true,
-    },
-    {
-      author: {
-        name: 'Mr. Peanutbutter2',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      id: 1,
-      line: 5,
-      message: 'This comment was left last but is not a draft',
-      updated: '2015-12-10 19:48:33.843000000',
-    },
-    {
-      author: {
-        name: 'Mr. Peanutbutter2',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      id: 3,
-      line: 5,
-      message: 'Later draft',
-      updated: '2015-12-09 19:48:33.843000000',
-      __draft: true,
-    }];
-    assert.equal(element._orderedComments[0].id, '1');
-    assert.equal(element._orderedComments[1].id, '2');
-    assert.equal(element._orderedComments[2].id, '3');
-  });
-
-  test('reflects lineNum and commentSide to attributes', () => {
-    element.lineNum = 7;
-    element.commentSide = 'left';
-
-    assert.equal(element.getAttribute('line-num'), '7');
-    assert.equal(element.getAttribute('comment-side'), 'left');
-  });
-
-  test('reflects range to JSON serialized attribute if set', () => {
-    element.range = {
-      start_line: 4,
-      end_line: 5,
-      start_character: 6,
-      end_character: 7,
-    };
-
-    assert.deepEqual(
-        JSON.parse(element.getAttribute('range')),
-        {start_line: 4, end_line: 5, start_character: 6, end_character: 7});
-  });
-
-  test('removes range attribute if range is unset', () => {
-    element.range = {
-      start_line: 4,
-      end_line: 5,
-      start_character: 6,
-      end_character: 7,
-    };
-    element.range = undefined;
-
-    assert.notOk(element.hasAttribute('range'));
-  });
-});
-
-suite('comment action tests on resolved comments', () => {
-  let element;
-
-  setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(false); },
-      saveDiffDraft() {
-        return Promise.resolve({
-          ok: true,
-          text() {
-            return Promise.resolve(')]}\'\n' +
-                JSON.stringify({
-                  id: '7afa4931_de3d65bd',
-                  path: '/path/to/file.txt',
-                  line: 5,
-                  in_reply_to: 'baf0414d_60047215',
-                  updated: '2015-12-21 02:01:10.850000000',
-                  message: 'Done',
-                }));
-          },
-        });
-      },
-      deleteDiffDraft() { return Promise.resolve({ok: true}); },
-    });
-    element = withCommentFixture.instantiate();
-    element.patchNum = '1';
-    element.changeNum = '1';
-    element.comments = [{
-      author: {
-        name: 'Mr. Peanutbutter',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      id: 'baf0414d_60047215',
-      line: 5,
-      message: 'is this a crossover episode!?',
-      updated: '2015-12-08 19:48:33.843000000',
-      path: '/path/to/file.txt',
-      unresolved: false,
-    }];
-    flush();
-  });
-
-  test('ack and done should be hidden', () => {
-    element.changeNum = '42';
-    element.patchNum = '1';
-
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const ackBtn = element.shadowRoot.querySelector('#ackBtn');
-    const doneBtn = element.shadowRoot.querySelector('#doneBtn');
-    assert.equal(ackBtn, null);
-    assert.equal(doneBtn, null);
-  });
-
-  test('reply and quote button should be visible', () => {
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const replyBtn = element.shadowRoot.querySelector('#replyBtn');
-    const quoteBtn = element.shadowRoot.querySelector('#quoteBtn');
-    assert.ok(replyBtn);
-    assert.ok(quoteBtn);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
new file mode 100644
index 0000000..f2df89d
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -0,0 +1,990 @@
+/**
+ * @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 './gr-comment-thread.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {SpecialFilePath, Side} from '../../../constants/constants.js';
+import {
+  sortComments,
+  UIComment,
+  UIRobot,
+  isDraft,
+  UIDraft,
+} from '../../../utils/comment-util.js';
+import {GrCommentThread} from './gr-comment-thread.js';
+import {
+  PatchSetNum,
+  NumericChangeId,
+  UrlEncodedCommentId,
+  Timestamp,
+  RobotId,
+  RobotRunId,
+  RepoName,
+  ConfigInfo,
+  EmailAddress,
+} from '../../../types/common.js';
+import {GrComment} from '../gr-comment/gr-comment.js';
+import {LineNumber} from '../../diff/gr-diff/gr-diff-line.js';
+import {
+  tap,
+  pressAndReleaseKeyOn,
+} from '@polymer/iron-test-helpers/mock-interactions';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {stubRestApi} from '../../../test/test-utils';
+
+const basicFixture = fixtureFromElement('gr-comment-thread');
+
+const withCommentFixture = fixtureFromElement('gr-comment-thread');
+
+suite('gr-comment-thread tests', () => {
+  suite('basic test', () => {
+    let element: GrCommentThread;
+
+    setup(() => {
+      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+
+      element = basicFixture.instantiate();
+      element.patchNum = 3 as PatchSetNum;
+      element.changeNum = 1 as NumericChangeId;
+      flush();
+    });
+
+    test('renders without patchNum and changeNum', async () => {
+      const fixture = fixtureFromTemplate(
+        html`<gr-comment-thread show-file-path="" path="path/to/file"></gr-change-metadata>`
+      );
+      fixture.instantiate();
+      await flush();
+    });
+
+    test('comments are sorted correctly', () => {
+      const comments: UIComment[] = [
+        {
+          message: 'i like you, too',
+          in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
+          __date: new Date('2015-12-25'),
+        },
+        {
+          id: 'sallys_confession' as UrlEncodedCommentId,
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:20.396000000' as Timestamp,
+        },
+        {
+          id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+          message: 'i’m running away',
+          updated: '2015-10-31 09:00:20.396000000' as Timestamp,
+        },
+        {
+          id: 'sallys_defiance' as UrlEncodedCommentId,
+          in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+          message: 'i will poison you so i can get away',
+          updated: '2015-10-31 15:00:20.396000000' as Timestamp,
+        },
+        {
+          id: 'dr_finklesteins_response' as UrlEncodedCommentId,
+          in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+          message: 'no i will pull a thread and your arm will fall off',
+          updated: '2015-10-31 11:00:20.396000000' as Timestamp,
+        },
+        {
+          id: 'sallys_mission' as UrlEncodedCommentId,
+          message: 'i have to find santa',
+          updated: '2015-12-24 15:00:20.396000000' as Timestamp,
+        },
+      ];
+      const results = sortComments(comments);
+      assert.deepEqual(results, [
+        {
+          id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+          message: 'i’m running away',
+          updated: '2015-10-31 09:00:20.396000000' as Timestamp,
+        },
+        {
+          id: 'dr_finklesteins_response' as UrlEncodedCommentId,
+          in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+          message: 'no i will pull a thread and your arm will fall off',
+          updated: '2015-10-31 11:00:20.396000000' as Timestamp,
+        },
+        {
+          id: 'sallys_defiance' as UrlEncodedCommentId,
+          in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+          message: 'i will poison you so i can get away',
+          updated: '2015-10-31 15:00:20.396000000' as Timestamp,
+        },
+        {
+          id: 'sallys_confession' as UrlEncodedCommentId,
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:20.396000000' as Timestamp,
+        },
+        {
+          id: 'sallys_mission' as UrlEncodedCommentId,
+          message: 'i have to find santa',
+          updated: '2015-12-24 15:00:20.396000000' as Timestamp,
+        },
+        {
+          message: 'i like you, too' as UrlEncodedCommentId,
+          in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
+          __date: new Date('2015-12-25'),
+        },
+      ]);
+    });
+
+    test('addOrEditDraft w/ edit draft', () => {
+      element.comments = [
+        {
+          id: 'jacks_reply' as UrlEncodedCommentId,
+          message: 'i like you, too',
+          in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
+          updated: '2015-12-25 15:00:20.396000000' as Timestamp,
+          __draft: true,
+        },
+      ];
+      const commentElStub = sinon
+        .stub(element, '_commentElWithDraftID')
+        .callsFake(() => {
+          return new GrComment();
+        });
+      const addDraftStub = sinon.stub(element, 'addDraft');
+
+      element.addOrEditDraft(123);
+
+      assert.isTrue(commentElStub.called);
+      assert.isFalse(addDraftStub.called);
+    });
+
+    test('addOrEditDraft w/o edit draft', () => {
+      element.comments = [];
+      const commentElStub = sinon
+        .stub(element, '_commentElWithDraftID')
+        .callsFake(() => {
+          return new GrComment();
+        });
+      const addDraftStub = sinon.stub(element, 'addDraft');
+
+      element.addOrEditDraft(123);
+
+      assert.isFalse(commentElStub.called);
+      assert.isTrue(addDraftStub.called);
+    });
+
+    test('_shouldDisableAction', () => {
+      let showActions = true;
+      const lastComment: UIComment = {};
+      assert.equal(
+        element._shouldDisableAction(showActions, lastComment),
+        false
+      );
+      showActions = false;
+      assert.equal(
+        element._shouldDisableAction(showActions, lastComment),
+        true
+      );
+      showActions = true;
+      lastComment.__draft = true;
+      assert.equal(
+        element._shouldDisableAction(showActions, lastComment),
+        true
+      );
+      const robotComment: UIRobot = {
+        id: '1234' as UrlEncodedCommentId,
+        updated: '1234' as Timestamp,
+        robot_id: 'robot_id' as RobotId,
+        robot_run_id: 'robot_run_id' as RobotRunId,
+        properties: {},
+        fix_suggestions: [],
+      };
+      assert.equal(
+        element._shouldDisableAction(showActions, robotComment),
+        false
+      );
+    });
+
+    test('_hideActions', () => {
+      let showActions = true;
+      const lastComment: UIComment = {};
+      assert.equal(element._hideActions(showActions, lastComment), false);
+      showActions = false;
+      assert.equal(element._hideActions(showActions, lastComment), true);
+      showActions = true;
+      lastComment.__draft = true;
+      assert.equal(element._hideActions(showActions, lastComment), true);
+      const robotComment: UIRobot = {
+        id: '1234' as UrlEncodedCommentId,
+        updated: '1234' as Timestamp,
+        robot_id: 'robot_id' as RobotId,
+        robot_run_id: 'robot_run_id' as RobotRunId,
+        properties: {},
+        fix_suggestions: [],
+      };
+      assert.equal(element._hideActions(showActions, robotComment), true);
+    });
+
+    test('setting project name loads the project config', done => {
+      const projectName = 'foo/bar/baz' as RepoName;
+      const getProjectStub = stubRestApi('getProjectConfig').returns(
+        Promise.resolve({} as ConfigInfo)
+      );
+      element.projectName = projectName;
+      flush(() => {
+        assert.isTrue(getProjectStub.calledWithExactly(projectName as never));
+        done();
+      });
+    });
+
+    test('optionally show file path', () => {
+      // Path info doesn't exist when showFilePath is false. Because it's in a
+      // dom-if it is not yet in the dom.
+      assert.isNotOk(element.shadowRoot?.querySelector('.pathInfo'));
+
+      const commentStub = sinon.stub(GerritNav, 'getUrlForComment');
+      element.changeNum = 123 as NumericChangeId;
+      element.projectName = 'test project' as RepoName;
+      element.path = 'path/to/file';
+      element.patchNum = 3 as PatchSetNum;
+      element.lineNum = 5;
+      element.comments = [{id: 'comment_id' as UrlEncodedCommentId}];
+      element.showFilePath = true;
+      flush();
+      assert.isOk(element.shadowRoot?.querySelector('.pathInfo'));
+      assert.notEqual(
+        getComputedStyle(element.shadowRoot!.querySelector('.pathInfo')!)
+          .display,
+        'none'
+      );
+      assert.isTrue(
+        commentStub.calledWithExactly(
+          element.changeNum,
+          element.projectName,
+          'comment_id' as UrlEncodedCommentId
+        )
+      );
+    });
+
+    test('_computeDisplayPath', () => {
+      let path = 'path/to/file';
+      assert.equal(element._computeDisplayPath(path), 'path/to/file');
+
+      element.lineNum = 5;
+      assert.equal(element._computeDisplayPath(path), 'path/to/file');
+
+      element.patchNum = 3 as PatchSetNum;
+      path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+      assert.equal(element._computeDisplayPath(path), 'Patchset');
+    });
+
+    test('_computeDisplayLine', () => {
+      element.lineNum = 5;
+      assert.equal(element._computeDisplayLine(), '#5');
+
+      element.path = SpecialFilePath.COMMIT_MESSAGE;
+      element.lineNum = 5;
+      assert.equal(element._computeDisplayLine(), '#5');
+
+      element.lineNum = undefined;
+      element.path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+      assert.equal(element._computeDisplayLine(), '');
+    });
+  });
+});
+
+suite('comment action tests with unresolved thread', () => {
+  let element: GrCommentThread;
+
+  setup(() => {
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    stubRestApi('saveDiffDraft').returns(
+      Promise.resolve(({
+        headers: {} as Headers,
+        redirected: false,
+        status: 200,
+        statusText: '',
+        type: '' as ResponseType,
+        url: '',
+        ok: true,
+        text() {
+          return Promise.resolve(
+            ")]}'\n" +
+              JSON.stringify({
+                id: '7afa4931_de3d65bd',
+                path: '/path/to/file.txt',
+                line: 5,
+                in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
+                updated: '2015-12-21 02:01:10.850000000',
+                message: 'Done',
+              })
+          );
+        },
+      } as unknown) as Response)
+    );
+    stubRestApi('deleteDiffDraft').returns(
+      Promise.resolve(({ok: true} as unknown) as Response)
+    );
+    element = withCommentFixture.instantiate();
+    element.patchNum = 1 as PatchSetNum;
+    element.changeNum = 1 as NumericChangeId;
+    element.comments = [
+      {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: ('tenn1sballchaser@aol.com' as EmailAddress) as EmailAddress,
+        },
+        id: 'baf0414d_60047215' as UrlEncodedCommentId,
+        line: 5,
+        message: 'is this a crossover episode!?',
+        updated: '2015-12-08 19:48:33.843000000' as Timestamp,
+        path: '/path/to/file.txt',
+        unresolved: true,
+        patch_set: 3 as PatchSetNum,
+      },
+    ];
+    flush();
+  });
+
+  test('reply', () => {
+    const commentEl = element.shadowRoot?.querySelector('gr-comment');
+    const reportStub = sinon.stub(element.reporting, 'recordDraftInteraction');
+    assert.ok(commentEl);
+
+    const replyBtn = element.$.replyBtn;
+    tap(replyBtn);
+    flush();
+
+    const drafts = element._orderedComments.filter(c => isDraft(c));
+    assert.equal(drafts.length, 1);
+    assert.notOk(drafts[0].message, 'message should be empty');
+    assert.equal(
+      drafts[0].in_reply_to,
+      ('baf0414d_60047215' as UrlEncodedCommentId) as UrlEncodedCommentId
+    );
+    assert.isTrue(reportStub.calledOnce);
+  });
+
+  test('quote reply', () => {
+    const commentEl = element.shadowRoot?.querySelector('gr-comment');
+    const reportStub = sinon.stub(element.reporting, 'recordDraftInteraction');
+    assert.ok(commentEl);
+
+    const quoteBtn = element.$.quoteBtn;
+    tap(quoteBtn);
+    flush();
+
+    const drafts = element._orderedComments.filter(c => isDraft(c));
+    assert.equal(drafts.length, 1);
+    assert.equal(drafts[0].message, '> is this a crossover episode!?\n\n');
+    assert.equal(
+      drafts[0].in_reply_to,
+      ('baf0414d_60047215' as UrlEncodedCommentId) as UrlEncodedCommentId
+    );
+    assert.isTrue(reportStub.calledOnce);
+  });
+
+  test('quote reply multiline', () => {
+    const reportStub = sinon.stub(element.reporting, 'recordDraftInteraction');
+    element.comments = [
+      {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: ('tenn1sballchaser@aol.com' as EmailAddress) as EmailAddress,
+        },
+        id: 'baf0414d_60047215' as UrlEncodedCommentId,
+        path: 'test',
+        line: 5,
+        message: 'is this a crossover episode!?\nIt might be!',
+        updated: '2015-12-08 19:48:33.843000000' as Timestamp,
+      },
+    ];
+    flush();
+
+    const commentEl = element.shadowRoot?.querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const quoteBtn = element.$.quoteBtn;
+    tap(quoteBtn);
+    flush();
+
+    const drafts = element._orderedComments.filter(c => isDraft(c));
+    assert.equal(drafts.length, 1);
+    assert.equal(
+      drafts[0].message,
+      '> is this a crossover episode!?\n> It might be!\n\n'
+    );
+    assert.equal(
+      drafts[0].in_reply_to,
+      'baf0414d_60047215' as UrlEncodedCommentId
+    );
+    assert.isTrue(reportStub.calledOnce);
+  });
+
+  test('ack', done => {
+    const reportStub = sinon.stub(element.reporting, 'recordDraftInteraction');
+    element.changeNum = 42 as NumericChangeId;
+    element.patchNum = 1 as PatchSetNum;
+
+    const commentEl = element.shadowRoot?.querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const ackBtn = element.shadowRoot?.querySelector('#ackBtn');
+    assert.isOk(ackBtn);
+    tap(ackBtn!);
+    flush(() => {
+      const drafts = element.comments.filter(c => isDraft(c));
+      assert.equal(drafts.length, 1);
+      assert.equal(drafts[0].message, 'Ack');
+      assert.equal(
+        drafts[0].in_reply_to,
+        'baf0414d_60047215' as UrlEncodedCommentId
+      );
+      assert.equal(drafts[0].unresolved, false);
+      assert.isTrue(reportStub.calledOnce);
+      done();
+    });
+  });
+
+  test('done', done => {
+    const reportStub = sinon.stub(element.reporting, 'recordDraftInteraction');
+    element.changeNum = 42 as NumericChangeId;
+    element.patchNum = 1 as PatchSetNum;
+    const commentEl = element.shadowRoot?.querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const doneBtn = element.shadowRoot?.querySelector('#doneBtn');
+    assert.isOk(doneBtn);
+    tap(doneBtn!);
+    flush(() => {
+      const drafts = element.comments.filter(c => isDraft(c));
+      assert.equal(drafts.length, 1);
+      assert.equal(drafts[0].message, 'Done');
+      assert.equal(
+        drafts[0].in_reply_to,
+        'baf0414d_60047215' as UrlEncodedCommentId
+      );
+      assert.isFalse(drafts[0].unresolved);
+      assert.isTrue(reportStub.calledOnce);
+      done();
+    });
+  });
+
+  test('save', done => {
+    element.changeNum = 42 as NumericChangeId;
+    element.patchNum = 1 as PatchSetNum;
+    element.path = '/path/to/file.txt';
+    const commentEl = element.shadowRoot?.querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const saveOrDiscardStub = sinon.stub();
+    element.addEventListener('thread-changed', saveOrDiscardStub);
+    element.shadowRoot?.querySelector('gr-comment')?._fireSave();
+
+    flush(() => {
+      assert.isTrue(saveOrDiscardStub.called);
+      assert.equal(
+        saveOrDiscardStub.lastCall.args[0].detail.rootId,
+        'baf0414d_60047215'
+      );
+      assert.equal(element.rootId, 'baf0414d_60047215' as UrlEncodedCommentId);
+      assert.equal(
+        saveOrDiscardStub.lastCall.args[0].detail.path,
+        '/path/to/file.txt'
+      );
+      done();
+    });
+  });
+
+  test('please fix', done => {
+    element.changeNum = 42 as NumericChangeId;
+    element.patchNum = 1 as PatchSetNum;
+    const commentEl = element.shadowRoot?.querySelector('gr-comment');
+    assert.ok(commentEl);
+    commentEl!.addEventListener('create-fix-comment', () => {
+      const drafts = element._orderedComments.filter(c => isDraft(c));
+      assert.equal(drafts.length, 1);
+      assert.equal(
+        drafts[0].message,
+        '> is this a crossover episode!?\n\nPlease fix.'
+      );
+      assert.equal(
+        drafts[0].in_reply_to,
+        'baf0414d_60047215' as UrlEncodedCommentId
+      );
+      assert.isTrue(drafts[0].unresolved);
+      done();
+    });
+    commentEl!.dispatchEvent(
+      new CustomEvent('create-fix-comment', {
+        detail: {comment: commentEl!.comment},
+        composed: true,
+        bubbles: false,
+      })
+    );
+  });
+
+  test('discard', done => {
+    element.changeNum = 42 as NumericChangeId;
+    element.patchNum = 1 as PatchSetNum;
+    element.path = '/path/to/file.txt';
+    assert.isOk(element.comments[0]);
+    element.push(
+      'comments',
+      element._newReply(
+        element.comments[0]!.id as UrlEncodedCommentId,
+        'it’s pronouced jiff, not giff'
+      )
+    );
+    flush();
+
+    const saveOrDiscardStub = sinon.stub();
+    element.addEventListener('thread-changed', saveOrDiscardStub);
+    const draftEl = element.root?.querySelectorAll('gr-comment')[1];
+    assert.ok(draftEl);
+    draftEl!.addEventListener('comment-discard', () => {
+      const drafts = element.comments.filter(c => isDraft(c));
+      assert.equal(drafts.length, 0);
+      assert.isTrue(saveOrDiscardStub.called);
+      assert.equal(
+        saveOrDiscardStub.lastCall.args[0].detail.rootId,
+        element.rootId
+      );
+      assert.equal(
+        saveOrDiscardStub.lastCall.args[0].detail.path,
+        element.path
+      );
+      done();
+    });
+    draftEl!.dispatchEvent(
+      new CustomEvent('comment-discard', {
+        detail: {comment: draftEl!.comment},
+        composed: true,
+        bubbles: false,
+      })
+    );
+  });
+
+  test('discard with a single comment still fires event with previous rootId', done => {
+    element.changeNum = 42 as NumericChangeId;
+    element.patchNum = 1 as PatchSetNum;
+    element.path = '/path/to/file.txt';
+    element.comments = [];
+    element.addOrEditDraft(1 as LineNumber);
+    flush();
+    const rootId = element.rootId;
+    assert.isOk(rootId);
+
+    const saveOrDiscardStub = sinon.stub();
+    element.addEventListener('thread-changed', saveOrDiscardStub);
+    const draftEl = element.root?.querySelectorAll('gr-comment')[0];
+    assert.ok(draftEl);
+    draftEl!.addEventListener('comment-discard', () => {
+      assert.equal(element.comments.length, 0);
+      assert.isTrue(saveOrDiscardStub.called);
+      assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId, rootId);
+      assert.equal(
+        saveOrDiscardStub.lastCall.args[0].detail.path,
+        element.path
+      );
+      done();
+    });
+    draftEl!.dispatchEvent(
+      new CustomEvent('comment-discard', {
+        detail: {comment: draftEl!.comment},
+        composed: true,
+        bubbles: false,
+      })
+    );
+  });
+
+  test(
+    'When not editing other comments, local storage not set' + ' after discard',
+    done => {
+      element.changeNum = 42 as NumericChangeId;
+      element.patchNum = 1 as PatchSetNum;
+      element.comments = [
+        {
+          author: {
+            name: 'Mr. Peanutbutter',
+            email: 'tenn1sballchaser@aol.com' as EmailAddress,
+          },
+          id: 'baf0414d_60047215' as UrlEncodedCommentId,
+          path: 'test',
+          line: 5,
+          message: 'is this a crossover episode!?',
+          updated: '2015-12-08 19:48:31.843000000' as Timestamp,
+        },
+        {
+          author: {
+            name: 'Mr. Peanutbutter',
+            email: 'tenn1sballchaser@aol.com' as EmailAddress,
+          },
+          __draftID: '1',
+          in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
+          path: 'test',
+          line: 5,
+          message: 'yes',
+          updated: '2015-12-08 19:48:32.843000000' as Timestamp,
+          __draft: true,
+          __editing: true,
+        },
+        {
+          author: {
+            name: 'Mr. Peanutbutter',
+            email: 'tenn1sballchaser@aol.com' as EmailAddress,
+          },
+          __draftID: '2',
+          in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
+          path: 'test',
+          line: 5,
+          message: 'no',
+          updated: '2015-12-08 19:48:33.843000000' as Timestamp,
+          __draft: true,
+        },
+      ];
+      const storageStub = sinon.stub(element.storage, 'setDraftComment');
+      flush();
+
+      const draftEl = element.root?.querySelectorAll('gr-comment')[1];
+      assert.ok(draftEl);
+      draftEl!.addEventListener('comment-discard', () => {
+        assert.isFalse(storageStub.called);
+        storageStub.restore();
+        done();
+      });
+      draftEl!.dispatchEvent(
+        new CustomEvent('comment-discard', {
+          detail: {comment: draftEl!.comment},
+          composed: true,
+          bubbles: false,
+        })
+      );
+    }
+  );
+
+  test('comment-update', () => {
+    const commentEl = element.shadowRoot?.querySelector('gr-comment');
+    const updatedComment = {
+      id: element.comments[0].id,
+      foo: 'bar',
+    };
+    assert.isOk(commentEl);
+    commentEl!.dispatchEvent(
+      new CustomEvent('comment-update', {
+        detail: {comment: updatedComment},
+        composed: true,
+        bubbles: true,
+      })
+    );
+    assert.strictEqual(element.comments[0], updatedComment);
+  });
+
+  suite('jack and sally comment data test consolidation', () => {
+    setup(() => {
+      element.comments = [
+        {
+          id: 'jacks_reply' as UrlEncodedCommentId,
+          message: 'i like you, too',
+          in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
+          updated: '2015-12-25 15:00:20.396000000' as Timestamp,
+          unresolved: false,
+        },
+        {
+          id: 'sallys_confession' as UrlEncodedCommentId,
+          in_reply_to: 'nonexistent_comment' as UrlEncodedCommentId,
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:20.396000000' as Timestamp,
+        },
+        {
+          id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+          in_reply_to: 'nonexistent_comment' as UrlEncodedCommentId,
+          message: 'i’m running away',
+          updated: '2015-10-31 09:00:20.396000000' as Timestamp,
+        },
+        {
+          id: 'sallys_defiance' as UrlEncodedCommentId,
+          message: 'i will poison you so i can get away',
+          updated: '2015-10-31 15:00:20.396000000' as Timestamp,
+        },
+      ];
+    });
+
+    test('orphan replies', () => {
+      assert.equal(4, element._orderedComments.length);
+    });
+
+    test('keyboard shortcuts', () => {
+      const expandCollapseStub = sinon.stub(element, '_expandCollapseComments');
+      pressAndReleaseKeyOn(element, 69, null, 'e');
+      assert.isTrue(expandCollapseStub.lastCall.calledWith(false));
+
+      pressAndReleaseKeyOn(element, 69, 'shift', 'e');
+      assert.isTrue(expandCollapseStub.lastCall.calledWith(true));
+    });
+
+    test('comment in_reply_to is either null or most recent comment', () => {
+      element._createReplyComment('dummy', true);
+      flush();
+      assert.equal(element._orderedComments.length, 5);
+      assert.equal(
+        element._orderedComments[4].in_reply_to,
+        'jacks_reply' as UrlEncodedCommentId
+      );
+    });
+
+    test('resolvable comments', () => {
+      assert.isFalse(element.unresolved);
+      element._createReplyComment('dummy', true, true);
+      flush();
+      assert.isTrue(element.unresolved);
+    });
+
+    test('_setInitialExpandedState with unresolved', () => {
+      element.unresolved = true;
+      element._setInitialExpandedState();
+      for (let i = 0; i < element.comments.length; i++) {
+        assert.isFalse(element.comments[i].collapsed);
+      }
+    });
+
+    test('_setInitialExpandedState without unresolved', () => {
+      element.unresolved = false;
+      element._setInitialExpandedState();
+      for (let i = 0; i < element.comments.length; i++) {
+        assert.isTrue(element.comments[i].collapsed);
+      }
+    });
+
+    test('_setInitialExpandedState with robot_ids', () => {
+      for (let i = 0; i < element.comments.length; i++) {
+        (element.comments[i] as UIRobot).robot_id = '123' as RobotId;
+      }
+      element._setInitialExpandedState();
+      for (let i = 0; i < element.comments.length; i++) {
+        assert.isFalse(element.comments[i].collapsed);
+      }
+    });
+
+    test('_setInitialExpandedState with collapsed state', () => {
+      element.comments[0].collapsed = false;
+      element.unresolved = false;
+      element._setInitialExpandedState();
+      assert.isFalse(element.comments[0].collapsed);
+      for (let i = 1; i < element.comments.length; i++) {
+        assert.isTrue(element.comments[i].collapsed);
+      }
+    });
+  });
+
+  test('_computeHostClass', () => {
+    assert.equal(element._computeHostClass(true), 'unresolved');
+    assert.equal(element._computeHostClass(false), '');
+  });
+
+  test('addDraft sets unresolved state correctly', () => {
+    let unresolved = true;
+    element.comments = [];
+    element.addDraft(undefined, undefined, unresolved);
+    assert.equal(element.comments[0].unresolved, true);
+
+    unresolved = false; // comment should get added as actually resolved.
+    element.comments = [];
+    element.addDraft(undefined, undefined, unresolved);
+    assert.equal(element.comments[0].unresolved, false);
+
+    element.comments = [];
+    element.addDraft();
+    assert.equal(element.comments[0].unresolved, true);
+  });
+
+  test('_newDraft with root', () => {
+    const draft = element._newDraft();
+    assert.equal(draft.patch_set, 3 as PatchSetNum);
+  });
+
+  test('_newDraft with no root', () => {
+    element.comments = [];
+    element.diffSide = Side.RIGHT;
+    element.patchNum = 2 as PatchSetNum;
+    const draft = element._newDraft();
+    assert.equal(draft.patch_set, 2 as PatchSetNum);
+  });
+
+  test('new comment gets created', () => {
+    element.comments = [];
+    element.addOrEditDraft(1);
+    assert.equal(element.comments.length, 1);
+    // Mock a submitted comment.
+    element.comments[0].id = (element.comments[0] as UIDraft)
+      .__draftID as UrlEncodedCommentId;
+    delete (element.comments[0] as UIDraft).__draft;
+    element.addOrEditDraft(1);
+    assert.equal(element.comments.length, 2);
+  });
+
+  test('unresolved label', () => {
+    element.unresolved = false;
+    const label = element.shadowRoot?.querySelector('#unresolvedLabel');
+    assert.isOk(label);
+    assert.isFalse(label!.hasAttribute('hidden'));
+    element.unresolved = true;
+    assert.isFalse(label!.hasAttribute('hidden'));
+  });
+
+  test('draft comments are at the end of orderedComments', () => {
+    element.comments = [
+      {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com' as EmailAddress,
+        },
+        id: '2' as UrlEncodedCommentId,
+        line: 5,
+        message: 'Earlier draft',
+        updated: '2015-12-08 19:48:33.843000000' as Timestamp,
+        __draft: true,
+      },
+      {
+        author: {
+          name: 'Mr. Peanutbutter2',
+          email: 'tenn1sballchaser@aol.com' as EmailAddress,
+        },
+        id: '1' as UrlEncodedCommentId,
+        line: 5,
+        message: 'This comment was left last but is not a draft',
+        updated: '2015-12-10 19:48:33.843000000' as Timestamp,
+      },
+      {
+        author: {
+          name: 'Mr. Peanutbutter2',
+          email: 'tenn1sballchaser@aol.com' as EmailAddress,
+        },
+        id: '3' as UrlEncodedCommentId,
+        line: 5,
+        message: 'Later draft',
+        updated: '2015-12-09 19:48:33.843000000' as Timestamp,
+        __draft: true,
+      },
+    ];
+    assert.equal(element._orderedComments[0].id, '1' as UrlEncodedCommentId);
+    assert.equal(element._orderedComments[1].id, '2' as UrlEncodedCommentId);
+    assert.equal(element._orderedComments[2].id, '3' as UrlEncodedCommentId);
+  });
+
+  test('reflects lineNum and commentSide to attributes', () => {
+    element.lineNum = 7;
+    element.diffSide = Side.LEFT;
+
+    assert.equal(element.getAttribute('line-num'), '7');
+    assert.equal(element.getAttribute('diff-side'), Side.LEFT);
+  });
+
+  test('reflects range to JSON serialized attribute if set', () => {
+    element.range = {
+      start_line: 4,
+      end_line: 5,
+      start_character: 6,
+      end_character: 7,
+    };
+
+    assert.isOk(element.getAttribute('range'));
+    assert.deepEqual(JSON.parse(element.getAttribute('range')!), {
+      start_line: 4,
+      end_line: 5,
+      start_character: 6,
+      end_character: 7,
+    });
+  });
+
+  test('removes range attribute if range is unset', () => {
+    element.range = {
+      start_line: 4,
+      end_line: 5,
+      start_character: 6,
+      end_character: 7,
+    };
+    element.range = undefined;
+
+    assert.notOk(element.hasAttribute('range'));
+  });
+});
+
+suite('comment action tests on resolved comments', () => {
+  let element: GrCommentThread;
+
+  setup(() => {
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    stubRestApi('saveDiffDraft').returns(
+      Promise.resolve(({
+        ok: true,
+        text() {
+          return Promise.resolve(
+            ")]}'\n" +
+              JSON.stringify({
+                id: '7afa4931_de3d65bd',
+                path: '/path/to/file.txt',
+                line: 5,
+                in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
+                updated: '2015-12-21 02:01:10.850000000',
+                message: 'Done',
+              })
+          );
+        },
+      } as unknown) as Response)
+    );
+    stubRestApi('deleteDiffDraft').returns(
+      Promise.resolve(({ok: true} as unknown) as Response)
+    );
+    element = withCommentFixture.instantiate();
+    element.patchNum = 1 as PatchSetNum;
+    element.changeNum = 1 as NumericChangeId;
+    element.comments = [
+      {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com' as EmailAddress,
+        },
+        id: 'baf0414d_60047215' as UrlEncodedCommentId,
+        line: 5,
+        message: 'is this a crossover episode!?',
+        updated: '2015-12-08 19:48:33.843000000' as Timestamp,
+        path: '/path/to/file.txt',
+        unresolved: false,
+      },
+    ];
+    flush();
+  });
+
+  test('ack and done should be hidden', () => {
+    element.changeNum = 42 as NumericChangeId;
+    element.patchNum = 1 as PatchSetNum;
+
+    const commentEl = element.shadowRoot?.querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const ackBtn = element.shadowRoot?.querySelector('#ackBtn');
+    const doneBtn = element.shadowRoot?.querySelector('#doneBtn');
+    assert.equal(ackBtn, null);
+    assert.equal(doneBtn, null);
+  });
+
+  test('reply and quote button should be visible', () => {
+    const commentEl = element.shadowRoot?.querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const replyBtn = element.shadowRoot?.querySelector('#replyBtn');
+    const quoteBtn = element.shadowRoot?.querySelector('#quoteBtn');
+    assert.ok(replyBtn);
+    assert.ok(quoteBtn);
+  });
+});
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..bf376f6 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -24,7 +24,6 @@
 import '../gr-formatted-text/gr-formatted-text';
 import '../gr-icons/gr-icons';
 import '../gr-overlay/gr-overlay';
-import '../gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-storage/gr-storage';
 import '../gr-textarea/gr-textarea';
 import '../gr-tooltip-content/gr-tooltip-content';
@@ -39,7 +38,7 @@
 import {getRootElement} from '../../../scripts/rootElement';
 import {appContext} from '../../../services/app-context';
 import {customElement, observe, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {GrTextarea} from '../gr-textarea/gr-textarea';
 import {GrStorage, StorageLocation} from '../gr-storage/gr-storage';
 import {GrOverlay} from '../gr-overlay/gr-overlay';
@@ -48,11 +47,11 @@
   NumericChangeId,
   ConfigInfo,
   PatchSetNum,
+  RepoName,
 } from '../../../types/common';
 import {GrButton} from '../gr-button/gr-button';
 import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
 import {GrDialog} from '../gr-dialog/gr-dialog';
-import {Side} from '../../../constants/constants';
 import {
   isDraft,
   UIComment,
@@ -60,13 +59,13 @@
   UIRobot,
 } from '../../../utils/comment-util';
 import {OpenFixPreviewEventDetail} from '../../../types/events';
+import {fireAlert} from '../../../utils/event-util';
+import {pluralize} from '../../../utils/string-util';
+import {assertIsDefined} from '../../../utils/common-util';
 
 const STORAGE_DEBOUNCE_INTERVAL = 400;
 const TOAST_DEBOUNCE_INTERVAL = 200;
 
-const SAVING_MESSAGE = 'Saving';
-const DRAFT_SINGULAR = 'draft...';
-const DRAFT_PLURAL = 'drafts...';
 const SAVED_MESSAGE = 'All changes saved';
 const UNSAVED_MESSAGE = 'Unable to save draft';
 
@@ -97,13 +96,17 @@
 
 export interface GrComment {
   $: {
-    restAPI: RestApiService & Element;
-    storage: GrStorage;
     container: HTMLDivElement;
     resolvedCheckbox: HTMLInputElement;
   };
 }
 
+const DEBOUNCER_FIRE_UPDATE = 'fire-update';
+
+const DEBOUNCER_STORE = 'store';
+
+const DEBOUNCER_DRAFT_TOAST = 'draft-toast';
+
 @customElement('gr-comment')
 export class GrComment extends KeyboardShortcutMixin(
   GestureEventListeners(LegacyElementMixin(PolymerElement))
@@ -157,11 +160,14 @@
   @property({type: Number})
   changeNum?: NumericChangeId;
 
+  @property({type: String})
+  projectName?: RepoName;
+
   @property({type: Object, notify: true, observer: '_commentChanged'})
-  comment?: UIComment | UIRobot;
+  comment?: UIComment;
 
   @property({type: Array})
-  comments?: (UIComment | UIRobot)[];
+  comments?: UIComment[];
 
   @property({type: Boolean, reflectToAttribute: true})
   isRobotComment = false;
@@ -213,15 +219,13 @@
   _isAdmin = false;
 
   @property({type: Object})
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   _xhrPromise?: Promise<any>; // Used for testing.
 
   @property({type: String, observer: '_messageTextChanged'})
   _messageText = '';
 
   @property({type: String})
-  commentSide?: Side;
-
-  @property({type: String})
   side?: string;
 
   @property({type: Boolean})
@@ -261,6 +265,9 @@
   @property({type: Object})
   _selfAccount?: AccountDetailInfo;
 
+  @property({type: Boolean})
+  showPortedComment = false;
+
   get keyBindings() {
     return {
       'ctrl+enter meta+enter ctrl+s meta+s': '_handleSaveKey',
@@ -268,12 +275,16 @@
     };
   }
 
+  private readonly restApiService = appContext.restApiService;
+
+  private readonly storage = new GrStorage();
+
   reporting = appContext.reportingService;
 
   /** @override */
   attached() {
     super.attached();
-    this.$.restAPI.getAccount().then(account => {
+    this.restApiService.getAccount().then(account => {
       this._selfAccount = account;
     });
     if (this.editing) {
@@ -289,7 +300,9 @@
   /** @override */
   detached() {
     super.detached();
-    this.cancelDebouncer('fire-update');
+    this.cancelDebouncer(DEBOUNCER_FIRE_UPDATE);
+    this.cancelDebouncer(DEBOUNCER_STORE);
+    this.cancelDebouncer(DEBOUNCER_DRAFT_TOAST);
     if (this.textarea) {
       this.textarea.closeDropdown();
     }
@@ -299,6 +312,24 @@
     return comment.author || this._selfAccount;
   }
 
+  _getUrlForComment(comment: UIComment) {
+    if (!this.changeNum || !this.projectName) return '';
+    if (!comment.id) throw new Error('comment must have an id');
+    return GerritNav.getUrlForComment(
+      this.changeNum as NumericChangeId,
+      this.projectName,
+      comment.id
+    );
+  }
+
+  _handlePortedMessageClick() {
+    assertIsDefined(this.comment, 'comment');
+    this.reporting.reportInteraction('navigate-to-original-comment', {
+      line: this.comment.line,
+      range: this.comment.range,
+    });
+  }
+
   @observe('editing')
   _onEditingChange(editing?: boolean) {
     this.dispatchEvent(
@@ -311,7 +342,7 @@
     if (!editing) return;
     // visibility based on cache this will make sure we only and always show
     // a tip once every Math.max(a day, period between creating comments)
-    const cachedVisibilityOfRespectfulTip = this.$.storage.getRespectfulTipVisibility();
+    const cachedVisibilityOfRespectfulTip = this.storage.getRespectfulTipVisibility();
     if (!cachedVisibilityOfRespectfulTip) {
       // we still want to show the tip with a probability of 30%
       if (this.getRandomNum(0, 3) >= 1) return;
@@ -322,7 +353,7 @@
         tip: this._respectfulReviewTip,
       });
       // update cache
-      this.$.storage.setRespectfulTipVisibility();
+      this.storage.setRespectfulTipVisibility();
     }
   }
 
@@ -341,7 +372,7 @@
       tip: this._respectfulReviewTip,
     });
     // add a 14-day delay to the tip cache
-    this.$.storage.setRespectfulTipVisibility(/* delayDays= */ 14);
+    this.storage.setRespectfulTipVisibility(/* delayDays= */ 14);
   }
 
   _onRespectfulReadMoreClick() {
@@ -403,7 +434,7 @@
   }
 
   _getIsAdmin() {
-    return this.$.restAPI.getIsAdmin();
+    return this.restApiService.getIsAdmin();
   }
 
   _computeDraftTooltip(unableToSave: boolean) {
@@ -439,7 +470,7 @@
         }
 
         this._eraseDraftComment();
-        return this.$.restAPI.getResponseObject(response).then(obj => {
+        return this.restApiService.getResponseObject(response).then(obj => {
           const resComment = (obj as unknown) as UIDraft;
           if (!isDraft(this.comment)) throw new Error('Can only save drafts.');
           resComment.__draft = true;
@@ -448,7 +479,6 @@
           if (this.comment?.__draftID) {
             resComment.__draftID = this.comment.__draftID;
           }
-          resComment.__commentSide = this.commentSide;
           this.comment = resComment;
           this._fireSave();
           return obj;
@@ -465,13 +495,11 @@
   _eraseDraftComment() {
     // Prevents a race condition in which removing the draft comment occurs
     // prior to it being saved.
-    this.cancelDebouncer('store');
+    this.cancelDebouncer(DEBOUNCER_STORE);
 
-    if (!this.comment?.path) throw new Error('Cannot erase Draft Comment');
-    if (this.changeNum === undefined) {
-      throw new Error('undefined changeNum');
-    }
-    this.$.storage.eraseDraftComment({
+    assertIsDefined(this.comment?.path, 'comment.path');
+    assertIsDefined(this.changeNum, 'changeNum');
+    this.storage.eraseDraftComment({
       changeNum: this.changeNum,
       patchNum: this._getPatchNum(),
       path: this.comment.path,
@@ -481,7 +509,7 @@
   }
 
   _commentChanged(comment: UIComment) {
-    this.editing = !!comment.__editing;
+    this.editing = isDraft(comment) && !!comment.__editing;
     this.resolved = !comment.unresolved;
     if (this.editing) {
       // It's a new draft/reply, notify.
@@ -517,7 +545,7 @@
   }
 
   _fireUpdate() {
-    this.debounce('fire-update', () => {
+    this.debounce(DEBOUNCER_FIRE_UPDATE, () => {
       this.dispatchEvent(
         new CustomEvent('comment-update', {
           detail: this._getEventPayload(),
@@ -551,7 +579,7 @@
         cancelButton.hidden = !editing;
       }
     }
-    if (this.comment) {
+    if (isDraft(this.comment)) {
       this.comment.__editing = this.editing;
     }
     if (!!editing !== !!previousValue) {
@@ -626,7 +654,7 @@
     const {path, line, range} = this.comment;
     if (path) {
       this.debounce(
-        'store',
+        DEBOUNCER_STORE,
         () => {
           const message = this._messageText;
           if (this.changeNum === undefined) {
@@ -643,9 +671,9 @@
           if ((!message || !message.length) && oldValue) {
             // If the draft has been modified to be empty, then erase the storage
             // entry.
-            this.$.storage.eraseDraftComment(commentLocation);
+            this.storage.eraseDraftComment(commentLocation);
           } else {
-            this.$.storage.setDraftComment(commentLocation, message);
+            this.storage.setDraftComment(commentLocation, message);
           }
         },
         STORAGE_DEBOUNCE_INTERVAL
@@ -708,7 +736,7 @@
   }
 
   _fireDiscard() {
-    this.cancelDebouncer('fire-update');
+    this.cancelDebouncer(DEBOUNCER_FIRE_UPDATE);
     this.dispatchEvent(
       new CustomEvent('comment-discard', {
         detail: this._getEventPayload(),
@@ -813,11 +841,7 @@
     if (numPending === 0) {
       return SAVED_MESSAGE;
     }
-    return [
-      SAVING_MESSAGE,
-      numPending,
-      numPending === 1 ? DRAFT_SINGULAR : DRAFT_PLURAL,
-    ].join(' ');
+    return `Saving ${pluralize(numPending, 'draft')}...`;
   }
 
   _showStartRequest() {
@@ -835,7 +859,7 @@
 
     // Cancel the debouncer so that error toasts from the error-manager will
     // not be overridden.
-    this.cancelDebouncer('draft-toast');
+    this.cancelDebouncer(DEBOUNCER_DRAFT_TOAST);
     this._updateRequestToast(
       this._numPendingDraftRequests.number,
       /* requestFailed=*/ true
@@ -845,18 +869,12 @@
   _updateRequestToast(numPending: number, requestFailed?: boolean) {
     const message = this._getSavingMessage(numPending, requestFailed);
     this.debounce(
-      'draft-toast',
+      DEBOUNCER_DRAFT_TOAST,
       () => {
         // Note: the event is fired on the body rather than this element because
         // this element may not be attached by the time this executes, in which
         // case the event would not bubble.
-        document.body.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message},
-            bubbles: true,
-            composed: true,
-          })
-        );
+        fireAlert(document.body, message);
       },
       TOAST_DEBOUNCE_INTERVAL
     );
@@ -873,7 +891,7 @@
       throw new Error('undefined draft or changeNum or patchNum');
     }
     this._showStartRequest();
-    return this.$.restAPI
+    return this.restApiService
       .saveDiffDraft(this.changeNum, this.patchNum, draft)
       .then(result => {
         if (result.ok) {
@@ -898,7 +916,7 @@
     }
     this._showStartRequest();
     if (!draft.id) throw new Error('Missing id in comment draft.');
-    return this.$.restAPI
+    return this.restApiService
       .deleteDiffDraft(this.changeNum, this.patchNum, {id: draft.id})
       .then(result => {
         if (result.ok) {
@@ -931,21 +949,11 @@
 
     // Only apply local drafts to comments that haven't been saved
     // remotely, and haven't been given a default message already.
-    //
-    // Don't get local draft if there is another comment that is currently
-    // in an editing state.
-    if (
-      !comment ||
-      comment.id ||
-      comment.message ||
-      comment.__otherEditing ||
-      !comment.path
-    ) {
-      if (comment) delete comment.__otherEditing;
+    if (!comment || comment.id || comment.message || !comment.path) {
       return;
     }
 
-    const draft = this.$.storage.getDraftComment({
+    const draft = this.storage.getDraftComment({
       changeNum,
       patchNum: this._getPatchNum(),
       path: comment.path,
@@ -1024,7 +1032,7 @@
     ) {
       throw new Error('undefined comment or id or changeNum or patchNum');
     }
-    this.$.restAPI
+    this.restApiService
       .deleteComment(
         this.changeNum,
         this.patchNum,
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..158e8574 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
@@ -239,22 +239,43 @@
       --gr-account-label-text-style: {
         font-weight: var(--font-weight-bold);
       }
-      --account-max-length: 120px;
-      width: 120px;
+      --account-max-length: 130px;
+      width: 150px;
     }
     .draft gr-account-label {
       width: unset;
     }
+    .portedMessage {
+      margin: 0 var(--spacing-m);
+    }
   </style>
   <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>
+        <template is="dom-if" if="[[showPortedComment]]">
+          <a href="[[_getUrlForComment(comment)]]"
+            ><span class="portedMessage" on-click="_handlePortedMessageClick"
+              >From patchset [[comment.patch_set]]</span
+            ></a
+          >
+          <a
+            href="https://bugs.chromium.org/p/gerrit/issues/entry?template=Porting+Comments"
+            target="_blank"
+          >
+            <iron-icon icon="gr-icons:bug" title="report a problem"></iron-icon>
+          </a>
+        </template>
         <gr-tooltip-content
           class="draftTooltip"
           has-tooltip=""
@@ -473,6 +494,4 @@
       </gr-dialog>
     </gr-overlay>
   </template>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  <gr-storage id="storage"></gr-storage>
 `;
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..6c925b0 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
@@ -19,7 +19,8 @@
 import './gr-comment.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 import {__testOnly_UNSAVED_MESSAGE} from './gr-comment.js';
-import {SpecialFilePath} from '../../../constants/constants.js';
+import {SpecialFilePath, Side} from '../../../constants/constants.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-comment');
 
@@ -39,16 +40,12 @@
     let openOverlaySpy;
 
     setup(() => {
-      stub('gr-rest-api-interface', {
-        getAccount() {
-          return Promise.resolve({
-            email: 'dhruvsri@google.com',
-            name: 'Dhruv Srivastava',
-            _account_id: 1083225,
-            avatars: [{url: 'abc', height: 32}],
-          });
-        },
-      });
+      stubRestApi('getAccount').returns(Promise.resolve({
+        email: 'dhruvsri@google.com',
+        name: 'Dhruv Srivastava',
+        _account_id: 1083225,
+        avatars: [{url: 'abc', height: 32}],
+      }));
       element = basicFixture.instantiate();
       element.comment = {
         author: {
@@ -118,7 +115,7 @@
     });
 
     test('message is not retrieved from storage when other edits', done => {
-      const storageStub = sinon.stub(element.$.storage, 'getDraftComment');
+      const storageStub = sinon.stub(element.storage, 'getDraftComment');
       const loadSpy = sinon.spy(element, '_loadLocalDraft');
 
       element.changeNum = 1;
@@ -129,7 +126,6 @@
           email: 'tenn1sballchaser@aol.com',
         },
         line: 5,
-        __otherEditing: true,
       };
       flush(() => {
         assert.isTrue(loadSpy.called);
@@ -139,7 +135,7 @@
     });
 
     test('message is retrieved from storage when no other edits', done => {
-      const storageStub = sinon.stub(element.$.storage, 'getDraftComment');
+      const storageStub = sinon.stub(element.storage, 'getDraftComment');
       const loadSpy = sinon.spy(element, '_loadLocalDraft');
 
       element.changeNum = 1;
@@ -275,8 +271,7 @@
     });
 
     test('delete comment', done => {
-      sinon.stub(
-          element.$.restAPI, 'deleteComment').returns(Promise.resolve({}));
+      const stub = stubRestApi('deleteComment').returns(Promise.resolve({}));
       sinon.spy(element.confirmDeleteOverlay, 'open');
       element.changeNum = 42;
       element.patchNum = 0xDEADBEEF;
@@ -293,7 +288,7 @@
                   .querySelector('#confirmDeleteComment');
           dialog.message = 'removal reason';
           element._handleConfirmDeleteComment();
-          assert.isTrue(element.$.restAPI.deleteComment.calledWith(
+          assert.isTrue(stub.calledWith(
               42, 0xDEADBEEF, 'baf0414d_60047215', 'removal reason'));
           done();
         });
@@ -375,7 +370,7 @@
       element.patchNum = 1;
       const updateRequestStub = sinon.stub(element, '_updateRequestToast');
       const diffDraftStub =
-        sinon.stub(element.$.restAPI, 'saveDiffDraft').returns(
+        stubRestApi('saveDiffDraft').returns(
             Promise.resolve({ok: false}));
       element._saveDraft({id: 'abc_123'});
       flush(() => {
@@ -411,7 +406,7 @@
       element.patchNum = 1;
       const updateRequestStub = sinon.stub(element, '_updateRequestToast');
       const diffDraftStub =
-        sinon.stub(element.$.restAPI, 'saveDiffDraft').returns(
+        stubRestApi('saveDiffDraft').returns(
             Promise.reject(new Error()));
       element._saveDraft({id: 'abc_123'});
       flush(() => {
@@ -446,44 +441,36 @@
     let element;
 
     setup(() => {
-      stub('gr-rest-api-interface', {
-        getAccount() { return Promise.resolve(null); },
-        getConfig() { return Promise.resolve({}); },
-        saveDiffDraft() {
-          return Promise.resolve({
-            ok: true,
-            text() {
-              return Promise.resolve(
-                  ')]}\'\n{' +
-                  '"id": "baf0414d_40572e03",' +
-                  '"path": "/path/to/file",' +
-                  '"line": 5,' +
-                  '"updated": "2015-12-08 21:52:36.177000000",' +
-                  '"message": "saved!"' +
-                '}'
-              );
-            },
-          });
+      stubRestApi('getAccount').returns(Promise.resolve(null));
+      stubRestApi('getConfig').returns(Promise.resolve({}));
+      stubRestApi('saveDiffDraft').returns(Promise.resolve({
+        ok: true,
+        text() {
+          return Promise.resolve(
+              ')]}\'\n{' +
+              '"id": "baf0414d_40572e03",' +
+              '"path": "/path/to/file",' +
+              '"line": 5,' +
+              '"updated": "2015-12-08 21:52:36.177000000",' +
+              '"message": "saved!"' +
+              '}'
+          );
         },
-        removeChangeReviewer() {
-          return Promise.resolve({ok: true});
-        },
-      });
-      stub('gr-storage', {
-        getDraftComment() { return null; },
-      });
+      }));
+      stubRestApi('removeChangeReviewer').returns(Promise.resolve({ok: true}));
       element = draftFixture.instantiate();
+      sinon.stub(element.storage, 'getDraftComment').returns(null);
       element.changeNum = 42;
       element.patchNum = 1;
       element.editing = false;
       element.comment = {
-        __commentSide: 'right',
+        diffSide: Side.RIGHT,
         __draft: true,
         __draftID: 'temp_draft_id',
         path: '/path/to/file',
         line: 5,
       };
-      element.commentSide = 'right';
+      element.diffSide = Side.RIGHT;
     });
 
     test('button visibility states', () => {
@@ -695,9 +682,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');
@@ -733,13 +719,13 @@
         path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS, line: undefined,
         range: undefined};
       element.comment = comment;
-      flushAsynchronousOperations();
+      flush();
       MockInteractions.tap(element.shadowRoot
           .querySelector('.edit'));
       assert.isTrue(element.editing);
 
       element._messageText = 'hello world';
-      const eraseMessageDraftSpy = sinon.spy(element.$.storage,
+      const eraseMessageDraftSpy = sinon.spy(element.storage,
           'eraseDraftComment');
       const mockEvent = {preventDefault: sinon.stub()};
       element._handleSave(mockEvent);
@@ -804,7 +790,7 @@
     test('storage is cleared only after save success', () => {
       element._messageText = 'test';
       const eraseStub = sinon.stub(element, '_eraseDraftComment');
-      sinon.stub(element.$.restAPI, 'getResponseObject')
+      stubRestApi('getResponseObject')
           .returns(Promise.resolve({}));
 
       sinon.stub(element, '_saveDraft').returns(Promise.resolve({ok: false}));
@@ -911,7 +897,6 @@
 
         assert.deepEqual(dispatchEventStub.lastCall.args[0].detail, {
           comment: {
-            __commentSide: 'right',
             __draft: true,
             __draftID: 'temp_draft_id',
             id: 'baf0414d_40572e03',
@@ -1039,8 +1024,8 @@
 
     test('cancelling an unsaved draft discards, persists in storage', () => {
       const discardSpy = sinon.spy(element, '_fireDiscard');
-      const storeStub = sinon.stub(element.$.storage, 'setDraftComment');
-      const eraseStub = sinon.stub(element.$.storage, 'eraseDraftComment');
+      const storeStub = sinon.stub(element.storage, 'setDraftComment');
+      const eraseStub = sinon.stub(element.storage, 'eraseDraftComment');
       element._messageText = 'test text';
       flush();
       element.flushDebouncer('store');
@@ -1055,7 +1040,7 @@
     test('cancelling edit on a saved draft does not store', () => {
       element.comment.id = 'foo';
       const discardSpy = sinon.spy(element, '_fireDiscard');
-      const storeStub = sinon.stub(element.$.storage, 'setDraftComment');
+      const storeStub = sinon.stub(element.storage, 'setDraftComment');
       element._messageText = 'test text';
       flush();
       element.flushDebouncer('store');
@@ -1139,7 +1124,7 @@
           updated: '2017-04-04 15:36:17.000000000',
           message: 'This is a robot comment with a fix.',
           unresolved: false,
-          __commentSide: 'right',
+          diffSide: Side.RIGHT,
           collapsed: false,
         },
         {
@@ -1149,7 +1134,7 @@
           path: 'Documentation/config-gerrit.txt',
           patchNum: 1,
           side: 'REVISION',
-          __commentSide: 'right',
+          diffSide: Side.RIGHT,
           line: 10,
           in_reply_to: 'eb0d03fd_5e95904f',
           message: '> This is a robot comment with a fix.\n\nPlease fix.',
@@ -1214,7 +1199,7 @@
           updated: '2017-04-04 15:36:17.000000000',
           message: 'This is a robot comment with a fix.',
           unresolved: false,
-          __commentSide: 'right',
+          diffSide: Side.RIGHT,
           collapsed: false,
         },
       ];
@@ -1243,9 +1228,7 @@
 
     let clock;
     setup(() => {
-      stub('gr-rest-api-interface', {
-        getAccount() { return Promise.resolve(null); },
-      });
+      stubRestApi('getAccount').returns(Promise.resolve(null));
       clock = sinon.useFakeTimers();
     });
 
@@ -1255,18 +1238,15 @@
     });
 
     test('show tip when no cached record', done => {
-      // fake stub for storage
-      const respectfulGetStub = sinon.stub();
-      const respectfulSetStub = sinon.stub();
-      stub('gr-storage', {
-        getRespectfulTipVisibility() { return respectfulGetStub(); },
-        setRespectfulTipVisibility() { return respectfulSetStub(); },
-      });
-      respectfulGetStub.returns(null);
       element = draftFixture.instantiate();
+      const respectfulGetStub =
+          sinon.stub(element.storage, 'getRespectfulTipVisibility');
+      const respectfulSetStub =
+          sinon.stub(element.storage, 'setRespectfulTipVisibility');
+      respectfulGetStub.returns(null);
       // fake random
       element.getRandomNum = () => 0;
-      element.comment = {__editing: true};
+      element.comment = {__editing: true, __draft: true};
       flush(() => {
         assert.isTrue(respectfulGetStub.called);
         assert.isTrue(respectfulSetStub.called);
@@ -1278,18 +1258,15 @@
     });
 
     test('add 14-day delays once dismissed', done => {
-      // fake stub for storage
-      const respectfulGetStub = sinon.stub();
-      const respectfulSetStub = sinon.stub();
-      stub('gr-storage', {
-        getRespectfulTipVisibility() { return respectfulGetStub(); },
-        setRespectfulTipVisibility(days) { return respectfulSetStub(days); },
-      });
-      respectfulGetStub.returns(null);
       element = draftFixture.instantiate();
+      const respectfulGetStub =
+          sinon.stub(element.storage, 'getRespectfulTipVisibility');
+      const respectfulSetStub =
+          sinon.stub(element.storage, 'setRespectfulTipVisibility');
+      respectfulGetStub.returns(null);
       // fake random
       element.getRandomNum = () => 0;
-      element.comment = {__editing: true};
+      element.comment = {__editing: true, __draft: true};
       flush(() => {
         assert.isTrue(respectfulGetStub.called);
         assert.isTrue(respectfulSetStub.called);
@@ -1307,18 +1284,15 @@
     });
 
     test('do not show tip when fall out of probability', done => {
-      // fake stub for storage
-      const respectfulGetStub = sinon.stub();
-      const respectfulSetStub = sinon.stub();
-      stub('gr-storage', {
-        getRespectfulTipVisibility() { return respectfulGetStub(); },
-        setRespectfulTipVisibility() { return respectfulSetStub(); },
-      });
-      respectfulGetStub.returns(null);
       element = draftFixture.instantiate();
+      const respectfulGetStub =
+          sinon.stub(element.storage, 'getRespectfulTipVisibility');
+      const respectfulSetStub =
+          sinon.stub(element.storage, 'setRespectfulTipVisibility');
+      respectfulGetStub.returns(null);
       // fake random
       element.getRandomNum = () => 3;
-      element.comment = {__editing: true};
+      element.comment = {__editing: true, __draft: true};
       flush(() => {
         assert.isTrue(respectfulGetStub.called);
         assert.isFalse(respectfulSetStub.called);
@@ -1330,15 +1304,12 @@
     });
 
     test('show tip when editing changed to true', done => {
-      // fake stub for storage
-      const respectfulGetStub = sinon.stub();
-      const respectfulSetStub = sinon.stub();
-      stub('gr-storage', {
-        getRespectfulTipVisibility() { return respectfulGetStub(); },
-        setRespectfulTipVisibility() { return respectfulSetStub(); },
-      });
-      respectfulGetStub.returns(null);
       element = draftFixture.instantiate();
+      const respectfulGetStub =
+          sinon.stub(element.storage, 'getRespectfulTipVisibility');
+      const respectfulSetStub =
+          sinon.stub(element.storage, 'setRespectfulTipVisibility');
+      respectfulGetStub.returns(null);
       // fake random
       element.getRandomNum = () => 0;
       element.comment = {__editing: false};
@@ -1362,18 +1333,15 @@
     });
 
     test('no tip when cached record', done => {
-      // fake stub for storage
-      const respectfulGetStub = sinon.stub();
-      const respectfulSetStub = sinon.stub();
-      stub('gr-storage', {
-        getRespectfulTipVisibility() { return respectfulGetStub(); },
-        setRespectfulTipVisibility() { return respectfulSetStub(); },
-      });
-      respectfulGetStub.returns({});
       element = draftFixture.instantiate();
+      const respectfulGetStub =
+          sinon.stub(element.storage, 'getRespectfulTipVisibility');
+      const respectfulSetStub =
+          sinon.stub(element.storage, 'setRespectfulTipVisibility');
+      respectfulGetStub.returns({});
       // fake random
       element.getRandomNum = () => 0;
-      element.comment = {__editing: true};
+      element.comment = {__editing: true, __draft: true};
       flush(() => {
         assert.isTrue(respectfulGetStub.called);
         assert.isFalse(respectfulSetStub.called);
diff --git a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.ts b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.ts
deleted file mode 100644
index bbbce16..0000000
--- a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.ts
+++ /dev/null
@@ -1,44 +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.
- */
-export const GrCountStringFormatter = {
-  /**
-   * Returns a count plus string that is pluralized when necessary.
-   */
-  computePluralString(count: number, noun: string): string {
-    return this.computeString(count, noun) + (count > 1 ? 's' : '');
-  },
-
-  /**
-   * Returns a count plus string that is not pluralized.
-   */
-  computeString(count: number, noun: string): string {
-    if (count === 0) {
-      return '';
-    }
-    return `${count} ${noun}`;
-  },
-
-  /**
-   * Returns a count plus arbitrary text.
-   */
-  computeShortString(count: number, text: string): string {
-    if (count === 0) {
-      return '';
-    }
-    return `${count}${text}`;
-  },
-};
diff --git a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.js b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.js
deleted file mode 100644
index 36637ec..0000000
--- a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.js
+++ /dev/null
@@ -1,47 +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 '../../../test/common-test-setup-karma.js';
-import {GrCountStringFormatter} from './gr-count-string-formatter.js';
-
-suite('gr-count-string-formatter tests', () => {
-  test('computeString', () => {
-    const noun = 'unresolved';
-    assert.equal(GrCountStringFormatter.computeString(0, noun), '');
-    assert.equal(GrCountStringFormatter.computeString(1, noun),
-        '1 unresolved');
-    assert.equal(GrCountStringFormatter.computeString(2, noun),
-        '2 unresolved');
-  });
-
-  test('computeShortString', () => {
-    const noun = 'c';
-    assert.equal(GrCountStringFormatter.computeShortString(0, noun), '');
-    assert.equal(GrCountStringFormatter.computeShortString(1, noun), '1c');
-    assert.equal(GrCountStringFormatter.computeShortString(2, noun), '2c');
-  });
-
-  test('computePluralString', () => {
-    const noun = 'comment';
-    assert.equal(GrCountStringFormatter.computePluralString(0, noun), '');
-    assert.equal(GrCountStringFormatter.computePluralString(1, noun),
-        '1 comment');
-    assert.equal(GrCountStringFormatter.computePluralString(2, noun),
-        '2 comments');
-  });
-});
-
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 9fdbb34..119ed20 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
@@ -54,8 +54,8 @@
 export type Stop = HTMLElement | AbortStop;
 
 /**
- * Type guard and checker to check if a stop can be targetted.
- * Abort stops cannot be targetted.
+ * Type guard and checker to check if a stop can be targeted.
+ * Abort stops cannot be targeted.
  */
 export function isTargetable(stop: Stop): stop is HTMLElement {
   return !(stop instanceof AbortStop);
@@ -261,14 +261,14 @@
     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() {
@@ -419,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 33aeafc..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
@@ -117,6 +117,16 @@
     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')];
     const result = element.next();
@@ -137,8 +147,6 @@
     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('previous() goes to last element when no cursor is set', () => {
@@ -162,8 +170,6 @@
     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', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
index c64dc2a..5bb4f4c 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/shared-styles';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
@@ -30,11 +29,12 @@
   isWithinHalfYear,
   formatDate,
   utcOffsetString,
+  wasYesterday,
 } from '../../../utils/date-util';
 import {TimeFormat, DateFormat} from '../../../constants/constants';
 import {assertNever} from '../../../utils/common-util';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {Timestamp} from '../../../types/common';
+import {appContext} from '../../../services/app-context';
 
 const TimeFormats = {
   TIME_12: 'h:mm A', // 2:14 PM
@@ -77,12 +77,6 @@
   }
 }
 
-export interface GrDateFormatter {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
-
 @customElement('gr-date-formatter')
 export class GrDateFormatter extends TooltipMixin(
   GestureEventListeners(LegacyElementMixin(PolymerElement))
@@ -104,6 +98,9 @@
   @property({type: Boolean})
   hasTooltip = false;
 
+  @property({type: Boolean})
+  showYesterday = false;
+
   /**
    * The title to be used as the native tooltip or by the tooltip behavior.
    */
@@ -130,6 +127,8 @@
   @property({type: Boolean})
   relativeOptionNoAgo = false;
 
+  private readonly restApiService = appContext.restApiService;
+
   constructor() {
     super();
   }
@@ -210,11 +209,11 @@
   }
 
   _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
+    return this.restApiService.getLoggedIn();
   }
 
   _getPreferences() {
-    return this.$.restAPI.getPreferences();
+    return this.restApiService.getPreferences();
   }
 
   _computeDateStr(
@@ -222,7 +221,8 @@
     timeFormat?: string,
     dateFormat?: DateFormatPair,
     relative?: boolean,
-    showDateAndTime?: boolean
+    showDateAndTime?: boolean,
+    showYesterday?: boolean
   ) {
     if (!dateStr || !timeFormat || !dateFormat) {
       return '';
@@ -238,6 +238,8 @@
     let format = dateFormat.full;
     if (isWithinDay(now, date)) {
       format = timeFormat;
+    } else if (showYesterday && wasYesterday(now, date)) {
+      return `Yesterday at ${formatDate(date, timeFormat)}`;
     } else {
       if (isWithinHalfYear(now, date)) {
         format = dateFormat.short;
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_html.ts b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_html.ts
index a5dd6d0..97bbf68 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_html.ts
@@ -25,7 +25,6 @@
   </style>
   <span>
     [[_computeDateStr(dateStr, _timeFormat, _dateFormat, _relative,
-    showDateAndTime)]]
+    showDateAndTime, showYesterday)]]
   </span>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.js b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.js
index 0a7f6dd..9a96c2d 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.js
@@ -19,6 +19,7 @@
 import './gr-date-formatter.js';
 import {parseDate} from '../../../utils/date-util.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromTemplate(html`
 <gr-date-formatter date-str="2015-09-24 23:30:17.033000000"></gr-date-formatter>
@@ -62,10 +63,8 @@
   function stubRestAPI(preferences) {
     const loggedInPromise = Promise.resolve(preferences !== null);
     const preferencesPromise = Promise.resolve(preferences);
-    stub('gr-rest-api-interface', {
-      getLoggedIn: sinon.stub().returns(loggedInPromise),
-      getPreferences: sinon.stub().returns(preferencesPromise),
-    });
+    stubRestApi('getLoggedIn').returns(loggedInPromise);
+    stubRestApi('getPreferences').returns(preferencesPromise);
     return Promise.all([loggedInPromise, preferencesPromise]);
   }
 
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..5810222 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
@@ -17,20 +17,18 @@
 import '@polymer/iron-input/iron-input';
 import '../../../styles/shared-styles';
 import '../gr-button/gr-button';
-import '../gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-select/gr-select';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-diff-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';
+import {appContext} from '../../../services/app-context';
 
 export interface GrDiffPreferences {
   $: {
-    restAPI: RestApiService & Element;
     lineWrappingInput: HTMLInputElement;
     showTabsInput: HTMLInputElement;
     showTrailingWhitespaceInput: HTMLInputElement;
@@ -55,8 +53,10 @@
   @property({type: Object})
   diffPrefs?: DiffPreferencesInfo;
 
+  private readonly restApiService = appContext.restApiService;
+
   loadData() {
-    return this.$.restAPI.getDiffPreferences().then(prefs => {
+    return this.restApiService.getDiffPreferences().then(prefs => {
       this.diffPrefs = prefs;
     });
   }
@@ -99,7 +99,7 @@
   save() {
     if (!this.diffPrefs)
       return Promise.reject(new Error('Missing diff preferences'));
-    return this.$.restAPI.saveDiffPreferences(this.diffPrefs).then(_ => {
+    return this.restApiService.saveDiffPreferences(this.diffPrefs).then(_ => {
       this.hasUnsavedChanges = false;
     });
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.ts
index 54022ba..246d192 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.ts
@@ -25,10 +25,11 @@
   </style>
   <div id="diffPreferences" class="gr-form-styles">
     <section>
-      <span class="title">Context</span>
+      <label for="contextLineSelect" class="title">Context</label>
       <span class="value">
         <gr-select id="contextSelect" bind-value="{{diffPrefs.context}}">
           <select
+            id="contextLineSelect"
             on-keypress="_handleDiffPrefsChanged"
             on-change="_handleDiffPrefsChanged"
           >
@@ -44,7 +45,7 @@
       </span>
     </section>
     <section>
-      <span class="title">Fit to screen</span>
+      <label for="lineWrappingInput" class="title">Fit to screen</label>
       <span class="value">
         <input
           id="lineWrappingInput"
@@ -55,7 +56,7 @@
       </span>
     </section>
     <section>
-      <span class="title">Diff width</span>
+      <label for="columnsInput" class="title">Diff width</label>
       <span class="value">
         <iron-input
           type="number"
@@ -79,7 +80,7 @@
       </span>
     </section>
     <section>
-      <span class="title">Tab width</span>
+      <label for="tabSizeInput" class="title">Tab width</label>
       <span class="value">
         <iron-input
           type="number"
@@ -103,7 +104,7 @@
       </span>
     </section>
     <section hidden$="[[!diffPrefs.font_size]]">
-      <span class="title">Font size</span>
+      <label for="fontSizeInput" class="title">Font size</label>
       <span class="value">
         <iron-input
           type="number"
@@ -127,7 +128,7 @@
       </span>
     </section>
     <section>
-      <span class="title">Show tabs</span>
+      <label for="showTabsInput" class="title">Show tabs</label>
       <span class="value">
         <input
           id="showTabsInput"
@@ -138,7 +139,9 @@
       </span>
     </section>
     <section>
-      <span class="title">Show trailing whitespace</span>
+      <label for="showTrailingWhitespaceInput" class="title"
+        >Show trailing whitespace</label
+      >
       <span class="value">
         <input
           id="showTrailingWhitespaceInput"
@@ -149,7 +152,9 @@
       </span>
     </section>
     <section>
-      <span class="title">Syntax highlighting</span>
+      <label for="syntaxHighlightInput" class="title"
+        >Syntax highlighting</label
+      >
       <span class="value">
         <input
           id="syntaxHighlightInput"
@@ -160,7 +165,9 @@
       </span>
     </section>
     <section>
-      <span class="title">Automatically mark viewed files reviewed</span>
+      <label for="automaticReviewInput" class="title"
+        >Automatically mark viewed files reviewed</label
+      >
       <span class="value">
         <input
           id="automaticReviewInput"
@@ -172,10 +179,11 @@
     </section>
     <section>
       <div class="pref">
-        <span class="title">Ignore Whitespace</span>
+        <label for="ignoreWhiteSpace" class="title">Ignore Whitespace</label>
         <span class="value">
           <gr-select bind-value="{{diffPrefs.ignore_whitespace}}">
             <select
+              id="ignoreWhiteSpace"
               on-keypress="_handleDiffPrefsChanged"
               on-change="_handleDiffPrefsChanged"
             >
@@ -191,5 +199,4 @@
       </div>
     </section>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.js b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.js
index ced8c6c..716ef2f 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-diff-preferences.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-diff-preferences');
 
@@ -50,11 +51,7 @@
       ignore_whitespace: 'IGNORE_NONE',
     };
 
-    stub('gr-rest-api-interface', {
-      getDiffPreferences() {
-        return Promise.resolve(diffPreferences);
-      },
-    });
+    stubRestApi('getDiffPreferences').returns(Promise.resolve(diffPreferences));
 
     element = basicFixture.instantiate();
 
@@ -89,7 +86,7 @@
   });
 
   test('save changes', () => {
-    sinon.stub(element.$.restAPI, 'saveDiffPreferences')
+    stubRestApi('saveDiffPreferences')
         .returns(Promise.resolve());
     const showTrailingWhitespaceCheckbox =
         valueOf('Show trailing whitespace', 'diffPreferences')
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
index 97747a0..4c2a417 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
@@ -16,7 +16,6 @@
  */
 import '@polymer/paper-tabs/paper-tabs';
 import '../gr-shell-command/gr-shell-command';
-import '../gr-rest-api-interface/gr-rest-api-interface';
 import '../../../styles/shared-styles';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
@@ -24,7 +23,7 @@
 import {htmlTemplate} from './gr-download-commands_html';
 import {customElement, property, observe} from '@polymer/decorators';
 import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {appContext} from '../../../services/app-context';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -35,7 +34,6 @@
 export interface GrDownloadCommands {
   $: {
     downloadTabs: PaperTabsElement;
-    restAPI: RestApiService & Element;
   };
 }
 
@@ -54,7 +52,7 @@
 
   // TODO(TS): maybe default to [] as only used in dom-repeat
   @property({type: Array})
-  comamnds?: Command[];
+  commands?: Command[];
 
   @property({type: Boolean})
   _loggedIn = false;
@@ -65,6 +63,8 @@
   @property({type: String, notify: true})
   selectedScheme?: string;
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
   attached() {
     super.attached();
@@ -79,7 +79,7 @@
   }
 
   _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
+    return this.restApiService.getLoggedIn();
   }
 
   @observe('_loggedIn')
@@ -87,7 +87,7 @@
     if (!loggedIn) {
       return;
     }
-    return this.$.restAPI.getPreferences().then(prefs => {
+    return this.restApiService.getPreferences().then(prefs => {
       if (prefs?.download_scheme) {
         // Note (issue 5180): normalize the download scheme with lower-case.
         this.selectedScheme = prefs.download_scheme.toLowerCase();
@@ -100,7 +100,9 @@
     if (scheme && scheme !== this.selectedScheme) {
       this.set('selectedScheme', scheme);
       if (this._loggedIn) {
-        this.$.restAPI.savePreferences({download_scheme: this.selectedScheme});
+        this.restApiService.savePreferences({
+          download_scheme: this.selectedScheme,
+        });
       }
     }
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.ts
index 35385fa..5c15b29 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.ts
@@ -72,5 +72,4 @@
       ></gr-shell-command>
     </template>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.js b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.js
index 5429506..6427c6b 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.js
@@ -17,7 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-download-commands.js';
-import {isHidden} from '../../../test/test-utils.js';
+import {isHidden, stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-download-commands');
 
@@ -50,6 +50,7 @@
 
   suite('unauthenticated', () => {
     setup(async () => {
+      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
       element = basicFixture.instantiate();
       element.schemes = SCHEMES;
       element.commands = COMMANDS;
@@ -66,16 +67,12 @@
     });
 
     test('element visibility', () => {
-      assert.isFalse(isHidden(element.shadowRoot
-          .querySelector('paper-tabs')));
-      assert.isFalse(isHidden(element.shadowRoot
-          .querySelector('.commands')));
+      assert.isFalse(isHidden(element.shadowRoot.querySelector('paper-tabs')));
+      assert.isFalse(isHidden(element.shadowRoot.querySelector('.commands')));
 
       element.schemes = [];
-      assert.isTrue(isHidden(element.shadowRoot
-          .querySelector('paper-tabs')));
-      assert.isTrue(isHidden(element.shadowRoot
-          .querySelector('.commands')));
+      assert.isTrue(isHidden(element.shadowRoot.querySelector('paper-tabs')));
+      assert.isTrue(isHidden(element.shadowRoot.querySelector('.commands')));
     });
 
     test('tab selection', () => {
@@ -87,35 +84,30 @@
       assert.equal(element.$.downloadTabs.selected, '2');
     });
 
-    test('loads scheme from preferences', () => {
-      stub('gr-rest-api-interface', {
-        getPreferences() {
-          return Promise.resolve({download_scheme: 'repo'});
-        },
-      });
+    test('loads scheme from preferences', async () => {
+      const stub = stubRestApi('getPreferences').returns(
+          Promise.resolve({download_scheme: 'repo'}));
       element._loggedIn = true;
-      assert.isTrue(element.$.restAPI.getPreferences.called);
-      return element.$.restAPI.getPreferences.lastCall.returnValue.then(() => {
-        assert.equal(element.selectedScheme, 'repo');
-      });
+      await flush();
+      assert.isTrue(stub.called);
+      await stub.lastCall.returnValue;
+      assert.equal(element.selectedScheme, 'repo');
     });
 
-    test('normalize scheme from preferences', () => {
-      stub('gr-rest-api-interface', {
-        getPreferences() {
-          return Promise.resolve({download_scheme: 'REPO'});
-        },
-      });
+    test('normalize scheme from preferences', async () => {
+      const stub = stubRestApi('getPreferences').returns(
+          Promise.resolve({download_scheme: 'REPO'}));
       element._loggedIn = true;
-      return element.$.restAPI.getPreferences.lastCall.returnValue.then(() => {
-        assert.equal(element.selectedScheme, 'repo');
-      });
+      await flush();
+      assert.isTrue(stub.called);
+      await stub.lastCall.returnValue;
+      assert.equal(element.selectedScheme, 'repo');
     });
 
     test('saves scheme to preferences', () => {
       element._loggedIn = true;
-      const savePrefsStub = sinon.stub(element.$.restAPI, 'savePreferences')
-          .callsFake(() => Promise.resolve());
+      const savePrefsStub = stubRestApi('savePreferences').returns(
+          Promise.resolve());
 
       flush();
 
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
index 2ea72ca..888f34f 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
@@ -21,6 +21,7 @@
 import '../gr-button/gr-button';
 import '../gr-date-formatter/gr-date-formatter';
 import '../gr-select/gr-select';
+import '../gr-file-status-chip/gr-file-status-chip';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
@@ -28,15 +29,10 @@
 import {customElement, property, observe} from '@polymer/decorators';
 import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
 import {Timestamp} from '../../../types/common';
+import {NormalizedFileInfo} from '../../change/gr-file-list/gr-file-list';
 
 /**
- * fired when the selected value of the dropdown changes
- *
- * @event {change}
- */
-
-/**
- * Requred values are text and value. mobileText and triggerText will
+ * Required values are text and value. mobileText and triggerText will
  * fall back to text if not provided.
  *
  * If bottomText is not provided, nothing will display on the second
@@ -52,6 +48,7 @@
   mobileText?: string;
   date?: Timestamp;
   disabled?: boolean;
+  file?: NormalizedFileInfo;
 }
 
 export interface GrDropdownList {
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 26a6b3f..c163924 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
@@ -145,7 +145,6 @@
       slot="dropdown-content"
       attr-for-selected="data-value"
       selected="{{value}}"
-      on-tap="_handleDropdownTap"
     >
       <template
         is="dom-repeat"
@@ -158,6 +157,9 @@
             <template is="dom-if" if="[[item.date]]">
               <gr-date-formatter date-str="[[item.date]]"></gr-date-formatter>
             </template>
+            <template is="dom-if" if="[[item.file]]">
+              <gr-file-status-chip file="[[item.file]]"></gr-file-status-chip>
+            </template>
           </div>
           <template is="dom-if" if="[[item.bottomText]]">
             <div class="bottomContent">
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.js b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.js
index e3d7ed70..c2c8d68 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-dropdown-list.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-dropdown-list');
 
@@ -24,9 +25,7 @@
   let element;
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-    });
+    stubRestApi('getConfig').returns(Promise.resolve({}));
     element = basicFixture.instantiate();
   });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
index d64b1c0..ae9bfd7 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
@@ -17,7 +17,6 @@
 import '@polymer/iron-dropdown/iron-dropdown';
 import '../gr-button/gr-button';
 import '../gr-cursor-manager/gr-cursor-manager';
-import '../gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-tooltip-content/gr-tooltip-content';
 import '../../../styles/shared-styles';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_html.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_html.ts
index 8ea0d21..c767edd 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_html.ts
@@ -165,5 +165,4 @@
     focus-on-move=""
     stops="[[_listElements]]"
   ></gr-cursor-manager>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.js b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.js
index 515644b..02bffe4 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-dropdown.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-dropdown');
 
@@ -25,9 +26,7 @@
   let element;
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-    });
+    stubRestApi('getConfig').returns(Promise.resolve({}));
     element = basicFixture.instantiate();
   });
 
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..2780fbe 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,9 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {customElement, property} from '@polymer/decorators';
 import {htmlTemplate} from './gr-editable-content_html';
+import {fireAlert, fireEvent} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {KnownExperimentId} from '../../../services/flags/flags';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
@@ -34,11 +37,7 @@
   }
 }
 
-export interface GrEditableContent {
-  $: {
-    storage: GrStorage;
-  };
-}
+const DEBOUNCER_STORE = 'store';
 
 @customElement('gr-editable-content')
 export class GrEditableContent extends GestureEventListeners(
@@ -72,7 +71,7 @@
   @property({type: Boolean, reflectToAttribute: true})
   disabled = false;
 
-  @property({type: Boolean, observer: '_editingChanged'})
+  @property({type: Boolean, observer: '_editingChanged', notify: true})
   editing = false;
 
   @property({type: Boolean})
@@ -82,6 +81,29 @@
   @property({type: String})
   storageKey?: string;
 
+  /** If false, then the "Show more" button was used to expand. */
+  @property({type: Boolean})
+  _commitCollapsed = true;
+
+  @property({type: Boolean})
+  commitCollapsible = true;
+
+  @property({
+    type: Boolean,
+    computed:
+      '_computeHideShowAllContainer(hideEditCommitMessage, _hideShowAllButton, editing)',
+  })
+  _hideShowAllContainer = false;
+
+  @property({
+    type: Boolean,
+    computed: '_computeHideShowAllButton(commitCollapsible, editing)',
+  })
+  _hideShowAllButton = false;
+
+  @property({type: Boolean})
+  hideEditCommitMessage?: boolean;
+
   @property({
     type: Boolean,
     computed: '_computeSaveDisabled(disabled, content, _newContent)',
@@ -91,6 +113,28 @@
   @property({type: String, observer: '_newContentChanged'})
   _newContent?: string;
 
+  @property({type: Boolean})
+  _isNewChangeSummaryUiEnabled = false;
+
+  private readonly storage = new GrStorage();
+
+  private readonly flagsService = appContext.flagsService;
+
+  private readonly reporting = appContext.reportingService;
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._isNewChangeSummaryUiEnabled = this.flagsService.isEnabled(
+      KnownExperimentId.NEW_CHANGE_SUMMARY_UI
+    );
+  }
+
+  /** @override */
+  detached() {
+    this.cancelDebouncer(DEBOUNCER_STORE);
+  }
+
   _contentChanged() {
     /* A changed content means that either a different change has been loaded
      * or new content was saved. Either way, let's reset the component.
@@ -108,17 +152,17 @@
     const storageKey = this.storageKey;
 
     this.debounce(
-      'store',
+      DEBOUNCER_STORE,
       () => {
         if (newContent.length) {
-          this.$.storage.setEditableContentItem(storageKey, newContent);
+          this.storage.setEditableContentItem(storageKey, newContent);
         } else {
           // This does not really happen, because we don't clear newContent
           // after saving (see below). So this only occurs when the user clears
-          // all the content in the editable textarea. But <gr-storage> cleans
+          // all the content in the editable textarea. But GrStorage cleans
           // up itself after one day, so we are not so concerned about leaving
           // some garbage behind.
-          this.$.storage.eraseEditableContentItem(storageKey);
+          this.storage.eraseEditableContentItem(storageKey);
         }
       },
       STORAGE_DEBOUNCE_INTERVAL_MS
@@ -144,18 +188,12 @@
 
     let content;
     if (this.storageKey) {
-      const storedContent = this.$.storage.getEditableContentItem(
+      const storedContent = this.storage.getEditableContentItem(
         this.storageKey
       );
       if (storedContent?.message) {
         content = storedContent.message;
-        this.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message: RESTORED_MESSAGE},
-            bubbles: true,
-            composed: true,
-          })
-        );
+        fireAlert(this, RESTORED_MESSAGE);
       }
     }
     if (!content) {
@@ -193,11 +231,43 @@
   _handleCancel(e: Event) {
     e.preventDefault();
     this.editing = false;
-    this.dispatchEvent(
-      new CustomEvent('editable-content-cancel', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'editable-content-cancel');
+  }
+
+  _computeCollapseText(collapsed: boolean) {
+    return collapsed ? 'Show all' : 'Show less';
+  }
+
+  _toggleCommitCollapsed() {
+    this._commitCollapsed = !this._commitCollapsed;
+    this.reporting.reportInteraction('toggle show all button', {
+      sectionName: 'Commit message',
+      toState: !this._commitCollapsed ? 'Show all' : 'Show less',
+    });
+    if (this._commitCollapsed) {
+      window.scrollTo(0, 0);
+    }
+  }
+
+  _computeHideShowAllContainer(
+    hideEditCommitMessage?: boolean,
+    _hideShowAllButton?: boolean,
+    editing?: boolean
+  ) {
+    if (editing) return false;
+    return _hideShowAllButton && hideEditCommitMessage;
+  }
+
+  _computeHideShowAllButton(commitCollapsible?: boolean, editing?: boolean) {
+    return !commitCollapsible || editing;
+  }
+
+  _computeCommitMessageCollapsed(collapsed?: boolean, collapsible?: boolean) {
+    return collapsible && collapsed;
+  }
+
+  _handleEditCommitMessage() {
+    this.editing = true;
+    this.focusTextarea();
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
index 81a2c2f..0f530bf 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
@@ -31,13 +31,19 @@
       box-shadow: var(--elevation-level-1);
       padding: var(--spacing-m);
     }
-    :host([collapsed]) .viewer {
-      max-height: 36em;
+    :host([collapsed]) .viewer,
+    .viewer.new-change-summary-true[collapsed] {
+      max-height: var(--collapsed-max-height, 300px);
       overflow: hidden;
     }
+    .editor.new-change-summary-true iron-autogrow-textarea,
+    .viewer.new-change-summary-true {
+      min-height: 100px;
+    }
     .editor iron-autogrow-textarea {
       background-color: var(--view-background-color);
       width: 100%;
+      display: block;
 
       /* You have to also repeat everything from shared-styles here, because
            you can only *replace* --iron-autogrow-textarea vars as a whole. */
@@ -52,24 +58,108 @@
       display: flex;
       justify-content: space-between;
     }
+    .show-all-container {
+      background-color: var(--view-background-color);
+      display: flex;
+      justify-content: flex-end;
+      margin-bottom: 8px;
+      border-top-width: 1px;
+      border-top-style: solid;
+      border-radius: 0 0 4px 4px;
+      border-color: var(--border-color);
+      box-shadow: var(--elevation-level-1);
+      /* slightly up to cover rounded corner of the commit msg */
+      margin-top: calc(-1 * var(--spacing-xs));
+      /* To make this bar pop over editor, since editor has relative position. 
+      */
+      position: relative;
+    }
+    .show-all-container .show-all-button {
+      margin-right: auto;
+    }
+    .show-all-container iron-icon {
+      color: inherit;
+      --iron-icon-height: 18px;
+      --iron-icon-width: 18px;
+    }
+    .cancel-button {
+      margin-right: var(--spacing-l);
+    }
+    .save-button {
+      margin-right: var(--spacing-xs);
+    }
   </style>
-  <div class="viewer" hidden$="[[editing]]">
+  <div
+    class$="viewer new-change-summary-[[_isNewChangeSummaryUiEnabled]]"
+    hidden$="[[editing]]"
+    collapsed$="[[_computeCommitMessageCollapsed(_commitCollapsed, commitCollapsible)]]"
+  >
     <slot></slot>
   </div>
-  <div class="editor" hidden$="[[!editing]]">
-    <iron-autogrow-textarea
-      autocomplete="on"
-      bind-value="{{_newContent}}"
-      disabled="[[disabled]]"
-    ></iron-autogrow-textarea>
-    <div class="editButtons">
-      <gr-button primary="" on-click="_handleSave" disabled="[[_saveDisabled]]"
-        >Save</gr-button
-      >
-      <gr-button on-click="_handleCancel" disabled="[[disabled]]"
-        >Cancel</gr-button
-      >
+  <div
+    class$="editor new-change-summary-[[_isNewChangeSummaryUiEnabled]]"
+    hidden$="[[!editing]]"
+  >
+    <div>
+      <iron-autogrow-textarea
+        autocomplete="on"
+        bind-value="{{_newContent}}"
+        disabled="[[disabled]]"
+      ></iron-autogrow-textarea>
+      <div class="editButtons" hidden$="[[_isNewChangeSummaryUiEnabled]]">
+        <gr-button
+          primary=""
+          on-click="_handleSave"
+          disabled="[[_saveDisabled]]"
+          >Save</gr-button
+        >
+        <gr-button on-click="_handleCancel" disabled="[[disabled]]"
+          >Cancel</gr-button
+        >
+      </div>
     </div>
   </div>
-  <gr-storage id="storage"></gr-storage>
+  <template is="dom-if" if="[[_isNewChangeSummaryUiEnabled]]">
+    <div class="show-all-container" hidden$="[[_hideShowAllContainer]]">
+      <gr-button
+        link=""
+        class="show-all-button"
+        on-click="_toggleCommitCollapsed"
+        hidden$="[[_hideShowAllButton]]"
+        ><iron-icon
+          icon="gr-icons:expand-more"
+          hidden$="[[!_commitCollapsed]]"
+        ></iron-icon
+        ><iron-icon
+          icon="gr-icons:expand-less"
+          hidden$="[[_commitCollapsed]]"
+        ></iron-icon>
+        [[_computeCollapseText(_commitCollapsed)]]
+      </gr-button>
+      <gr-button
+        link=""
+        class="edit-commit-message"
+        title="Edit commit message"
+        on-click="_handleEditCommitMessage"
+        hidden$="[[hideEditCommitMessage]]"
+        ><iron-icon icon="gr-icons:edit"></iron-icon> Edit</gr-button
+      >
+      <div class="editButtons" hidden$="[[!editing]]">
+        <gr-button
+          link=""
+          class="cancel-button"
+          on-click="_handleCancel"
+          disabled="[[disabled]]"
+          >Cancel</gr-button
+        >
+        <gr-button
+          class="save-button"
+          primary=""
+          on-click="_handleSave"
+          disabled="[[_saveDisabled]]"
+          >Save</gr-button
+        >
+      </div>
+    </div>
+  </template>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.js b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.js
index 129fda8..b99b119 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.js
@@ -99,29 +99,29 @@
     });
 
     test('editing toggled to true, has stored data', () => {
-      sinon.stub(element.$.storage, 'getEditableContentItem')
+      sinon.stub(element.storage, 'getEditableContentItem')
           .returns({message: 'stored content'});
       element.editing = true;
 
       assert.equal(element._newContent, 'stored content');
       assert.isTrue(dispatchSpy.called);
-      assert.equal(dispatchSpy.lastCall.args[0].type, 'show-alert');
+      assert.equal(dispatchSpy.firstCall.args[0].type, 'show-alert');
     });
 
     test('editing toggled to true, has no stored data', () => {
-      sinon.stub(element.$.storage, 'getEditableContentItem')
+      sinon.stub(element.storage, 'getEditableContentItem')
           .returns({});
       element.editing = true;
 
       assert.equal(element._newContent, 'current content');
-      assert.isFalse(dispatchSpy.called);
+      assert.equal(dispatchSpy.firstCall.args[0].type, 'editing-changed');
     });
 
     test('edits are cached', () => {
       const storeStub =
-          sinon.stub(element.$.storage, 'setEditableContentItem');
+          sinon.stub(element.storage, 'setEditableContentItem');
       const eraseStub =
-          sinon.stub(element.$.storage, 'eraseEditableContentItem');
+          sinon.stub(element.storage, 'eraseEditableContentItem');
       element.editing = true;
 
       element._newContent = 'new content';
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
index 9e1a5bf..6f0e84a 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
@@ -89,6 +89,9 @@
   @property({type: Number})
   readonly _verticalOffset = -30;
 
+  @property({type: Boolean})
+  showAsEditPencil = false;
+
   /** @override */
   ready() {
     super.ready();
@@ -133,7 +136,7 @@
     this._inputText = this.value;
     this.editing = true;
 
-    return new Promise(resolve => {
+    return new Promise<void>(resolve => {
       this._awaitOpen(resolve);
     });
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.ts
index 5e36166..ad4fead 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.ts
@@ -68,13 +68,33 @@
       }
       --paper-input-container-focus-color: var(--link-color);
     }
+    gr-button iron-icon {
+      color: inherit;
+      --iron-icon-height: 18px;
+      --iron-icon-width: 18px;
+    }
+    gr-button.new-change-summary-true {
+      --padding: 1px 4px;
+    }
   </style>
-  <label
-    class$="[[_computeLabelClass(readOnly, value, placeholder)]]"
-    title$="[[_computeLabel(value, placeholder)]]"
-    on-click="_showDropdown"
-    >[[_computeLabel(value, placeholder)]]</label
-  >
+  <template is="dom-if" if="[[!showAsEditPencil]]">
+    <label
+      class$="[[_computeLabelClass(readOnly, value, placeholder)]]"
+      title$="[[_computeLabel(value, placeholder)]]"
+      aria-label$="[[_computeLabel(value, placeholder)]]"
+      on-click="_showDropdown"
+      >[[_computeLabel(value, placeholder)]]</label
+    >
+  </template>
+  <template is="dom-if" if="[[showAsEditPencil]]">
+    <gr-button
+      link=""
+      class$="new-change-summary-true [[_computeLabelClass(readOnly, value, placeholder)]]"
+      on-click="_showDropdown"
+      title="[[_computeLabel(value, placeholder)]]"
+      ><iron-icon icon="gr-icons:edit"></iron-icon
+    ></gr-button>
+  </template>
   <iron-dropdown
     id="dropdown"
     vertical-align="auto"
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js
index d8f085e..2bdc570 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js
@@ -45,6 +45,7 @@
   setup(async () => {
     element = basicFixture.instantiate();
     elementNoPlaceholder = noPlaceholderFixture.instantiate();
+    flush();
     label = element.shadowRoot.querySelector('label');
 
     await flush();
@@ -178,6 +179,7 @@
 
     setup(() => {
       element = readOnlyFixture.instantiate();
+      flush();
       label = element.shadowRoot
           .querySelector('label');
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip.ts b/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip.ts
new file mode 100644
index 0000000..9298fa3
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip.ts
@@ -0,0 +1,90 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {htmlTemplate} from './gr-file-status-chip_html';
+import {customElement, property} from '@polymer/decorators';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {SpecialFilePath} from '../../../constants/constants';
+import {NormalizedFileInfo} from '../../change/gr-file-list/gr-file-list';
+import {hasOwnProperty} from '../../../utils/common-util';
+
+const FileStatus = {
+  A: 'Added',
+  C: 'Copied',
+  D: 'Deleted',
+  M: 'Modified',
+  R: 'Renamed',
+  W: 'Rewritten',
+  U: 'Unchanged',
+};
+
+@customElement('gr-file-status-chip')
+export class GrFileStatusChip extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Object})
+  file?: NormalizedFileInfo;
+
+  /**
+   * Get a descriptive label for use in the status indicator's tooltip and
+   * ARIA label.
+   */
+  _computeFileStatusLabel(status?: keyof typeof FileStatus) {
+    const statusCode = this._computeFileStatus(status);
+    return hasOwnProperty(FileStatus, statusCode)
+      ? FileStatus[statusCode]
+      : 'Status Unknown';
+  }
+
+  _computeClass(baseClass?: string, path?: string) {
+    const classes = [];
+    if (baseClass) {
+      classes.push(baseClass);
+    }
+    if (
+      path === SpecialFilePath.COMMIT_MESSAGE ||
+      path === SpecialFilePath.MERGE_LIST
+    ) {
+      classes.push('invisible');
+    }
+    return classes.join(' ');
+  }
+
+  _computeFileStatus(
+    status?: keyof typeof FileStatus
+  ): keyof typeof FileStatus {
+    return status || 'M';
+  }
+
+  _computeStatusClass(file?: NormalizedFileInfo) {
+    if (!file) return '';
+    const classStr = this._computeClass('status', file.__path);
+    return `${classStr} ${this._computeFileStatus(file.status)}`;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-file-status-chip': GrFileStatusChip;
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip_html.ts b/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip_html.ts
new file mode 100644
index 0000000..9e49868
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip_html.ts
@@ -0,0 +1,51 @@
+/**
+ * @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 '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    .status {
+      display: inline-block;
+      border-radius: var(--border-radius);
+      margin-left: var(--spacing-s);
+      padding: 0 var(--spacing-m);
+      color: var(--primary-text-color);
+      font-size: var(--font-size-small);
+      background-color: var(--dark-add-highlight-color);
+    }
+    .status.invisible,
+    .status.M {
+      display: none;
+    }
+    .status.D,
+    .status.R,
+    .status.W {
+      background-color: var(--dark-remove-highlight-color);
+    }
+    .status.U {
+      background-color: var(--comment-background-color);
+    }
+  </style>
+  <span
+    class$="[[_computeStatusClass(file)]]"
+    tabindex="0"
+    title$="[[_computeFileStatusLabel(file.status)]]"
+    aria-label$="[[_computeFileStatusLabel(file.status)]]"
+  >
+    [[_computeFileStatusLabel(file.status)]]
+  </span>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip_test.ts b/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip_test.ts
new file mode 100644
index 0000000..0abc85f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip_test.ts
@@ -0,0 +1,46 @@
+/**
+ * @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 './gr-file-status-chip';
+import {GrFileStatusChip} from './gr-file-status-chip';
+
+const fixture = fixtureFromElement('gr-file-status-chip');
+
+suite('gr-file-status-chip tests', () => {
+  let element: GrFileStatusChip;
+
+  setup(() => {
+    element = fixture.instantiate();
+  });
+
+  test('computed properties', () => {
+    assert.equal(element._computeFileStatus('A'), 'A');
+    assert.equal(element._computeFileStatus(undefined), 'M');
+
+    assert.equal(element._computeClass('clazz', '/foo/bar/baz'), 'clazz');
+    assert.equal(
+      element._computeClass('clazz', '/COMMIT_MSG'),
+      'clazz invisible'
+    );
+  });
+
+  test('_computeFileStatusLabel', () => {
+    assert.equal(element._computeFileStatusLabel('A'), 'Added');
+    assert.equal(element._computeFileStatusLabel('M'), 'Modified');
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts
index bc1dfe0..1688a0d 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts
@@ -59,7 +59,8 @@
     gr-linked-text.pre {
       font-family: var(--monospace-font-family);
       font-size: var(--font-size-code);
-      line-height: var(--line-height-code);
+      /* usually 16px = 12px + 4px */
+      line-height: calc(var(--font-size-code) + var(--spacing-s));
     }
   </style>
   <div id="container"></div>
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
index da2881e..ddae8ea 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
@@ -19,17 +19,15 @@
 import '../../../styles/shared-styles';
 import '../gr-avatar/gr-avatar';
 import '../gr-button/gr-button';
-import '../gr-rest-api-interface/gr-rest-api-interface';
 import {hovercardBehaviorMixin} from '../gr-hovercard/gr-hovercard-behavior';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-hovercard-account_html';
 import {appContext} from '../../../services/app-context';
-import {accountKey} from '../../../utils/account-util';
+import {accountKey, isSelf} from '../../../utils/account-util';
 import {getDisplayName} from '../../../utils/display-name-util';
 import {customElement, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {
   AccountInfo,
   ChangeInfo,
@@ -45,13 +43,10 @@
   isAttentionSetEnabled,
 } from '../../../utils/attention-set-util';
 import {ReviewerState} from '../../../constants/constants';
-import {isRemovableReviewer} from '../../../utils/change-util';
+import {CURRENT} from '../../../utils/patch-set-util';
+import {isInvolved, isRemovableReviewer} from '../../../utils/change-util';
+import {assertIsDefined} from '../../../utils/common-util';
 
-export interface GrHovercardAccount {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
 @customElement('gr-hovercard-account')
 export class GrHovercardAccount extends GestureEventListeners(
   hovercardBehaviorMixin(LegacyElementMixin(PolymerElement))
@@ -94,6 +89,8 @@
 
   reporting: ReportingService;
 
+  private readonly restApiService = appContext.restApiService;
+
   constructor() {
     super();
     this.reporting = appContext.reportingService;
@@ -101,17 +98,17 @@
 
   attached() {
     super.attached();
-    this.$.restAPI.getConfig().then(config => {
+    this.restApiService.getConfig().then(config => {
       this._config = config;
     });
-    this.$.restAPI.getAccount().then(account => {
+    this.restApiService.getAccount().then(account => {
       this._selfAccount = account;
     });
   }
 
   _computeText(account?: AccountInfo, selfAccount?: AccountInfo) {
     if (!account || !selfAccount) return '';
-    return account._account_id === selfAccount._account_id ? 'Your' : 'Their';
+    return isSelf(account, selfAccount) ? 'Your' : 'Their';
   }
 
   get isAttentionEnabled() {
@@ -167,7 +164,7 @@
   }
 
   _handleChangeReviewerOrCCStatus() {
-    if (!this.change) throw new Error('expected change object to be present');
+    assertIsDefined(this.change, 'change');
     // accountKey() throws an error if _account_id & email is not found, which
     // we want to check before showing reloading toast
     const _accountKey = accountKey(this.account);
@@ -185,8 +182,8 @@
       },
     ];
 
-    this.$.restAPI
-      .saveChangeReview(this.change._number, 'current', reviewInput)
+    this.restApiService
+      .saveChangeReview(this.change._number, CURRENT, reviewInput)
       .then(response => {
         if (!response || !response.ok) {
           throw new Error(
@@ -204,7 +201,7 @@
     this.dispatchEventThroughTarget('show-alert', {
       message: 'Reloading page...',
     });
-    this.$.restAPI
+    this.restApiService
       .removeChangeReviewer(
         this.change._number,
         (this.account?._account_id || this.account?.email)!
@@ -223,15 +220,17 @@
   }
 
   _computeShowActionAddToAttentionSet() {
-    return (
-      this._selfAccount && this.isAttentionEnabled && !this.hasUserAttention
-    );
+    const involvedOrSelf =
+      isInvolved(this.change, this._selfAccount) ||
+      isSelf(this.account, this._selfAccount);
+    return involvedOrSelf && this.isAttentionEnabled && !this.hasUserAttention;
   }
 
   _computeShowActionRemoveFromAttentionSet() {
-    return (
-      this._selfAccount && this.isAttentionEnabled && this.hasUserAttention
-    );
+    const involvedOrSelf =
+      isInvolved(this.change, this._selfAccount) ||
+      isSelf(this.account, this._selfAccount);
+    return involvedOrSelf && this.isAttentionEnabled && this.hasUserAttention;
   }
 
   _handleClickAddToAttentionSet() {
@@ -256,7 +255,7 @@
       'attention-hovercard-add',
       this._reportingDetails()
     );
-    this.$.restAPI
+    this.restApiService
       .addToAttentionSet(this.change._number, this.account._account_id, reason)
       .then(() => {
         this.dispatchEventThroughTarget('hide-alert');
@@ -283,7 +282,7 @@
       'attention-hovercard-remove',
       this._reportingDetails()
     );
-    this.$.restAPI
+    this.restApiService
       .removeFromAttentionSet(
         this.change._number,
         this.account._account_id,
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
index 1d437fb..d0986de 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
@@ -111,15 +111,6 @@
               [[_computeText(account, _selfAccount)]] turn to take action.
             </span>
             <a
-              href="https://bugs.chromium.org/p/gerrit/issues/entry?template=Attention+Set"
-              target="_blank"
-            >
-              <iron-icon
-                icon="gr-icons:bug"
-                title="report a problem"
-              ></iron-icon>
-            </a>
-            <a
               href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
               target="_blank"
             >
@@ -144,7 +135,7 @@
       </template>
       <template
         is="dom-if"
-        if="[[_computeShowActionAddToAttentionSet(_config, highlightAttention, account, change)]]"
+        if="[[_computeShowActionAddToAttentionSet(_config, highlightAttention, account, change, _selfAccount)]]"
       >
         <div class="action">
           <gr-button
@@ -159,7 +150,7 @@
       </template>
       <template
         is="dom-if"
-        if="[[_computeShowActionRemoveFromAttentionSet(_config, highlightAttention, account, change)]]"
+        if="[[_computeShowActionRemoveFromAttentionSet(_config, highlightAttention, account, change, _selfAccount)]]"
       >
         <div class="action">
           <gr-button
@@ -172,7 +163,10 @@
           </gr-button>
         </div>
       </template>
-      <template is="dom-if" if="[[_showReviewerOrCCActions(account, change)]]">
+      <template
+        is="dom-if"
+        if="[[_showReviewerOrCCActions(account, change, _selfAccount)]]"
+      >
         <div class="action">
           <gr-button
             class="removeReviewerOrCC"
@@ -196,5 +190,4 @@
       </template>
     </template>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
index b09f0ce..1c67e13 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
@@ -19,6 +19,7 @@
 import './gr-hovercard-account.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 import {ReviewerState} from '../../../constants/constants.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromTemplate(html`
 <gr-hovercard-account class="hovered"></gr-hovercard-account>
@@ -34,22 +35,20 @@
     _account_id: '31415926535',
   };
 
-  setup(() => {
-    element = basicFixture.instantiate();
-    sinon.stub(element.$.restAPI, 'getAccount').returns(
-        new Promise(resolve => { '2'; })
+  setup(async () => {
+    stubRestApi('getAccount').returns(Promise.resolve({...ACCOUNT}));
+    stubRestApi('getConfig').returns(
+        Promise.resolve({change: {enable_attention_set: true}})
     );
-
-    element._selfAccount = {...ACCOUNT};
+    element = basicFixture.instantiate();
     element.account = {...ACCOUNT};
-    element._config = {
-      change: {enable_attention_set: true},
-    };
     element.change = {
       attention_set: {},
+      reviewers: {},
+      owner: {...ACCOUNT},
     };
     element.show({});
-    flush();
+    await flush();
   });
 
   teardown(() => {
@@ -110,8 +109,7 @@
         [ReviewerState.REVIEWER]: [ACCOUNT],
       },
     };
-    sinon.stub(element.$.restAPI, 'removeChangeReviewer').returns(
-        Promise.resolve({ok: true}));
+    stubRestApi('removeChangeReviewer').returns(Promise.resolve({ok: true}));
     const reloadListener = sinon.spy();
     element._target.addEventListener('reload', reloadListener);
     flush();
@@ -130,11 +128,10 @@
         [ReviewerState.REVIEWER]: [ACCOUNT],
       },
     };
-    const saveReviewStub = sinon.stub(element.$.restAPI,
+    const saveReviewStub = stubRestApi(
         'saveChangeReview').returns(
         Promise.resolve({ok: true}));
-    sinon.stub(element.$.restAPI, 'removeChangeReviewer').returns(
-        Promise.resolve({ok: true}));
+    stubRestApi('removeChangeReviewer').returns(Promise.resolve({ok: true}));
     const reloadListener = sinon.spy();
     element._target.addEventListener('reload', reloadListener);
 
@@ -157,11 +154,9 @@
         [ReviewerState.REVIEWER]: [],
       },
     };
-    const saveReviewStub = sinon.stub(element.$.restAPI,
-        'saveChangeReview').returns(
-        Promise.resolve({ok: true}));
-    sinon.stub(element.$.restAPI, 'removeChangeReviewer').returns(
-        Promise.resolve({ok: true}));
+    const saveReviewStub = stubRestApi(
+        'saveChangeReview').returns(Promise.resolve({ok: true}));
+    stubRestApi('removeChangeReviewer').returns(Promise.resolve({ok: true}));
     const reloadListener = sinon.spy();
     element._target.addEventListener('reload', reloadListener);
     flush();
@@ -184,8 +179,7 @@
         [ReviewerState.REVIEWER]: [],
       },
     };
-    sinon.stub(element.$.restAPI, 'removeChangeReviewer').returns(
-        Promise.resolve({ok: true}));
+    stubRestApi('removeChangeReviewer').returns(Promise.resolve({ok: true}));
     const reloadListener = sinon.spy();
     element._target.addEventListener('reload', reloadListener);
 
@@ -206,8 +200,7 @@
     const apiPromise = new Promise(r => {
       apiResolve = r;
     });
-    sinon.stub(element.$.restAPI, 'addToAttentionSet')
-        .callsFake(() => apiPromise);
+    stubRestApi('addToAttentionSet').returns(apiPromise);
     element.highlightAttention = true;
     element._target = document.createElement('div');
     flush();
@@ -239,10 +232,13 @@
     const apiPromise = new Promise(r => {
       apiResolve = r;
     });
-    sinon.stub(element.$.restAPI, 'removeFromAttentionSet')
-        .callsFake(() => apiPromise);
+    stubRestApi('removeFromAttentionSet').returns(apiPromise);
     element.highlightAttention = true;
-    element.change = {attention_set: {31415926535: {}}};
+    element.change = {
+      attention_set: {31415926535: {}},
+      reviewers: {},
+      owner: {...ACCOUNT},
+    };
     element._target = document.createElement('div');
     flush();
     const showAlertListener = sinon.spy();
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
index 78b6cda..b8f0161 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
@@ -28,12 +28,7 @@
   pushScrollLock,
   removeScrollLock,
 } from '@polymer/iron-overlay-behavior/iron-scroll-manager';
-
-interface ShowAlertEventDetail {
-  message: string;
-  dismissOnNavigation?: boolean;
-}
-
+import {ShowAlertEventDetail} from '../../../types/events';
 interface ReloadEventDetail {
   clearPatchset?: boolean;
 }
@@ -115,13 +110,13 @@
       @property({type: String})
       containerId = 'gr-hovercard-container';
 
-      private _hideDebouncer: Debouncer | null = null;
+      private hideDebouncer: Debouncer | null = null;
 
-      private _showDebouncer: Debouncer | null = null;
+      private showDebouncer: Debouncer | null = null;
 
-      private _isScheduledToShow?: boolean;
+      private isScheduledToShow?: boolean;
 
-      private _isScheduledToHide?: boolean;
+      private isScheduledToHide?: boolean;
 
       /** @override */
       attached() {
@@ -148,6 +143,8 @@
 
       detached() {
         super.detached();
+        this.cancelShowDebouncer();
+        this.cancelHideDebouncer();
         this.unlock();
       }
 
@@ -177,24 +174,24 @@
 
       debounceHide() {
         this.cancelShowDebouncer();
-        if (!this._isShowing || this._isScheduledToHide) return;
-        this._isScheduledToHide = true;
-        this._hideDebouncer = Debouncer.debounce(
-          this._hideDebouncer,
+        if (!this._isShowing || this.isScheduledToHide) return;
+        this.isScheduledToHide = true;
+        this.hideDebouncer = Debouncer.debounce(
+          this.hideDebouncer,
           timeOut.after(HIDE_DELAY_MS),
           () => {
             // This happens when hide immediately through click or mouse leave
             // on the hovercard
-            if (!this._isScheduledToHide) return;
+            if (!this.isScheduledToHide) return;
             this.hide();
           }
         );
       }
 
       cancelHideDebouncer() {
-        if (this._hideDebouncer) {
-          this._hideDebouncer.cancel();
-          this._isScheduledToHide = false;
+        if (this.hideDebouncer) {
+          this.hideDebouncer.cancel();
+          this.isScheduledToHide = false;
         }
       }
 
@@ -308,23 +305,23 @@
        */
       debounceShowBy(delayMs: number) {
         this.cancelHideDebouncer();
-        if (this._isShowing || this._isScheduledToShow) return;
-        this._isScheduledToShow = true;
-        this._showDebouncer = Debouncer.debounce(
-          this._showDebouncer,
+        if (this._isShowing || this.isScheduledToShow) return;
+        this.isScheduledToShow = true;
+        this.showDebouncer = Debouncer.debounce(
+          this.showDebouncer,
           timeOut.after(delayMs),
           () => {
             // This happens when the mouse leaves the target before the delay is over.
-            if (!this._isScheduledToShow) return;
+            if (!this.isScheduledToShow) return;
             this.show();
           }
         );
       }
 
       cancelShowDebouncer() {
-        if (this._showDebouncer) {
-          this._showDebouncer.cancel();
-          this._isScheduledToShow = false;
+        if (this.showDebouncer) {
+          this.showDebouncer.cancel();
+          this.isScheduledToShow = false;
         }
       }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.js b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.js
index 6b2e620..628b1e9 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.js
@@ -120,18 +120,18 @@
     button.dispatchEvent(new CustomEvent('mouseenter'));
 
     await enterPromise;
-    assert.isTrue(element._isScheduledToShow);
-    element._showDebouncer.flush();
+    assert.isTrue(element.isScheduledToShow);
+    element.showDebouncer.flush();
     assert.isTrue(element._isShowing);
-    assert.isFalse(element._isScheduledToShow);
+    assert.isFalse(element.isScheduledToShow);
 
     button.dispatchEvent(new CustomEvent('mouseleave'));
 
     await leavePromise;
-    assert.isTrue(element._isScheduledToHide);
+    assert.isTrue(element.isScheduledToHide);
     assert.isTrue(element._isShowing);
-    element._hideDebouncer.flush();
-    assert.isFalse(element._isScheduledToShow);
+    element.hideDebouncer.flush();
+    assert.isFalse(element.isScheduledToShow);
     assert.isFalse(element._isShowing);
 
     button.removeEventListener('mouseenter', enterResolve);
@@ -152,11 +152,11 @@
     button.dispatchEvent(new CustomEvent('mouseenter'));
 
     await enterPromise;
-    assert.isTrue(element._isScheduledToShow);
+    assert.isTrue(element.isScheduledToShow);
     MockInteractions.tap(button);
 
     await clickPromise;
-    assert.isFalse(element._isScheduledToShow);
+    assert.isFalse(element.isScheduledToShow);
     assert.isFalse(element._isShowing);
 
     button.removeEventListener('mouseenter', enterResolve);
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
index 7112e3b..7745da8 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
@@ -63,6 +63,8 @@
       <g id="hourglass"><path d="M6 2v6h.01L6 8.01 10 12l-4 4 .01.01H6V22h12v-5.99h-.01L18 16l-4-4 4-3.99-.01-.01H18V2H6z"></path><path d="M0 0h24v24H0V0z" fill="none"></path></g>
       <!-- This SVG is a copy from material.io https://material.io/icons/#mode_comment-->
       <g id="comment"><path d="M21.99 4c0-1.1-.89-2-1.99-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4-.01-18z"></path><path d="M0 0h24v24H0z" fill="none"></path></g>
+      <!-- This SVG is a copy from material.io https://material.io/resources/icons/?icon=mode_comment&style=outline-->
+      <g id="comment-outline"><path d="M0 0h24v24H0V0z" fill="none"></path><path d="M20 17.17L18.83 16H4V4h16v13.17zM20 2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4V4c0-1.1-.9-2-2-2z"></path></g>
       <!-- This SVG is a copy from material.io https://material.io/icons/#calendar_today-->
       <g id="calendar"><path d="M20 3h-1V1h-2v2H7V1H5v2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 18H4V8h16v13z"></path><path d="M0 0h24v24H0z" fill="none"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
@@ -77,8 +79,10 @@
       <g id="content-copy"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"></path></g>
       <!-- This is a custom PolyGerrit SVG -->
       <g id="check"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="check-circle"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"></path></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#check_circle-->
+      <g id="check-circle"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#check_circle_outline-->
+      <g id="check-circle-outline"><path d="M0 0h24v24H0V0zm0 0h24v24H0V0z" fill="none"/><path d="M16.59 7.58L10 14.17l-3.59-3.58L5 12l5 5 8-8zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/></g>
       <!-- This is a custom PolyGerrit SVG -->
       <g id="robot"><path d="M4.137453,5.61015591 L4.54835569,1.5340419 C4.5717665,1.30180904 4.76724872,1.12504213 5.00065859,1.12504213 C5.23327176,1.12504213 5.42730868,1.30282046 5.44761309,1.53454578 L5.76084628,5.10933916 C6.16304484,5.03749412 6.57714381,5 7,5 L17,5 C20.8659932,5 24,8.13400675 24,12 L24,15.1250421 C24,18.9910354 20.8659932,22.1250421 17,22.1250421 L7,22.1250421 C3.13400675,22.1250421 2.19029351e-15,18.9910354 0,15.1250421 L0,12 C-3.48556243e-16,9.15382228 1.69864167,6.70438358 4.137453,5.61015591 Z M5.77553049,6.12504213 C3.04904264,6.69038358 1,9.10590202 1,12 L1,15.1250421 C1,18.4387506 3.6862915,21.1250421 7,21.1250421 L17,21.1250421 C20.3137085,21.1250421 23,18.4387506 23,15.1250421 L23,12 C23,8.6862915 20.3137085,6 17,6 L7,6 C6.60617231,6 6.2212068,6.03794347 5.84855971,6.11037415 L5.84984496,6.12504213 L5.77553049,6.12504213 Z M6.93003717,6.95027711 L17.1232083,6.95027711 C19.8638332,6.95027711 22.0855486,9.17199258 22.0855486,11.9126175 C22.0855486,14.6532424 19.8638332,16.8749579 17.1232083,16.8749579 L6.93003717,16.8749579 C4.18941226,16.8749579 1.9676968,14.6532424 1.9676968,11.9126175 C1.9676968,9.17199258 4.18941226,6.95027711 6.93003717,6.95027711 Z M7.60124392,14.0779303 C9.03787127,14.0779303 10.2024878,12.9691885 10.2024878,11.6014862 C10.2024878,10.2337839 9.03787127,9.12504213 7.60124392,9.12504213 C6.16461657,9.12504213 5,10.2337839 5,11.6014862 C5,12.9691885 6.16461657,14.0779303 7.60124392,14.0779303 Z M16.617997,14.1098288 C18.0638768,14.1098288 19.2359939,12.9939463 19.2359939,11.6174355 C19.2359939,10.2409246 18.0638768,9.12504213 16.617997,9.12504213 C15.1721172,9.12504213 14,10.2409246 14,11.6174355 C14,12.9939463 15.1721172,14.1098288 16.617997,14.1098288 Z M9.79751216,18.1250421 L15,18.1250421 L15,19.1250421 C15,19.6773269 14.5522847,20.1250421 14,20.1250421 L10.7975122,20.1250421 C10.2452274,20.1250421 9.79751216,19.6773269 9.79751216,19.1250421 L9.79751216,18.1250421 Z"></path></g>
       <!-- This is a custom PolyGerrit SVG -->
@@ -112,6 +116,22 @@
       <g id="schedule"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"></path></g>
       <!-- This SVG is a copy from material.io https://material.io/icons/#bug_report-->
       <g id="bug"><path d="M0 0h24v24H0z" fill="none"/><path d="M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5c-.49 0-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z"/></g>
+      <!-- This SVG is a copy from material.io https://fonts.gstatic.com/s/i/googlematerialicons/move_item/v1/24px.svg -->
+      <g id="move-item"><path d="M15,19H5V5h10v4h2V5c0-1.1-0.89-2-2-2H5C3.9,3,3,3.9,3,5v14c0,1.1,0.9,2,2,2h10c1.11,0,2-0.9,2-2v-4h-2V19z"/><polygon points="20.01,8.01 18.59,9.41 20.17,11 8,11 8,13 20.17,13 18.59,14.59 20.01,15.99 24,12"/></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#warning-->
+      <g id="warning"><path d="M0 0h24v24H0z" fill="none"/><path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#timelapse-->
+      <g id="timelapse"><path d="M0 0h24v24H0z" fill="none"/><path d="M16.24 7.76C15.07 6.59 13.54 6 12 6v6l-4.24 4.24c2.34 2.34 6.14 2.34 8.49 0 2.34-2.34 2.34-6.14-.01-8.48zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#mark_chat_read-->
+      <g id="markChatRead"><path d="M12,18l-6,0l-4,4V4c0-1.1,0.9-2,2-2h16c1.1,0,2,0.9,2,2v7l-2,0V4H4v12l8,0V18z M23,14.34l-1.41-1.41l-4.24,4.24l-2.12-2.12 l-1.41,1.41L17.34,20L23,14.34z"/></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#message-->
+      <g id="message"><path d="M0 0h24v24H0z" fill="none"/><path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"/></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#launch-->
+      <g id="launch"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#filter-->
+      <g id="filter"><path d="M0,0h24 M24,24H0" fill="none"/><path d="M4.25,5.61C6.27,8.2,10,13,10,13v6c0,0.55,0.45,1,1,1h2c0.55,0,1-0.45,1-1v-6c0,0,3.72-4.8,5.74-7.39 C20.25,4.95,19.78,4,18.95,4H5.04C4.21,4,3.74,4.95,4.25,5.61z"/><path d="M0,0h24v24H0V0z" fill="none"/></g>
+      <!-- This is just a placeholder, i.e. an empty icon that has the same size as a normal icon. -->
+      <g id="placeholder"><path d="M0 0h24v24H0z" fill="none"/></g>
     </defs>
   </svg>
 </iron-iconset-svg>`;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.ts
index 0cb628c..9f9ba6a 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.ts
@@ -18,6 +18,8 @@
 import {GrAnnotation} from '../../diff/gr-diff-highlight/gr-annotation';
 import {GrStyleObject} from '../../plugins/gr-styles-api/gr-styles-api';
 import {GrDiffLine} from '../../diff/gr-diff/gr-diff-line';
+import {appContext} from '../../../services/app-context';
+import {AnnotationContext} from '../../../api/annotation';
 
 /**
  * Used to create a context for GrAnnotationActionsInterface.
@@ -27,14 +29,13 @@
  * @param lineNumberEl The TD element of the line number to
  * apply the annotation to using annotateLineNumber.
  * @param line The line object.
- * @param path The file path (eg: /COMMIT_MSG').
+ * @param path The file path (eg: '/COMMIT_MSG').
  * @param changeNum The Gerrit change number.
- * @param patchNum The Gerrit patch number.
  */
-export class GrAnnotationActionsContext {
-  private _contentEl: HTMLElement;
+export class GrAnnotationActionsContext implements AnnotationContext {
+  contentEl: HTMLElement;
 
-  private _lineNumberEl: HTMLElement;
+  lineNumberEl: HTMLElement;
 
   line: GrDiffLine;
 
@@ -42,6 +43,8 @@
 
   changeNum: number;
 
+  private readonly reporting = appContext.reportingService;
+
   constructor(
     contentEl: HTMLElement,
     lineNumberEl: HTMLElement,
@@ -49,15 +52,14 @@
     path: string,
     changeNum: string | number
   ) {
-    this._contentEl = contentEl;
-    this._lineNumberEl = lineNumberEl;
-
+    this.contentEl = contentEl;
+    this.lineNumberEl = lineNumberEl;
     this.line = line;
     this.path = path;
     this.changeNum = Number(changeNum);
     if (isNaN(this.changeNum)) {
-      console.error(
-        `GrAnnotationActionsContext: Invalid changeNum: ${changeNum}`
+      this.reporting.error(
+        new Error(`GrAnnotationActionsContext: Invalid changeNum: ${changeNum}`)
       );
     }
   }
@@ -76,12 +78,12 @@
     styleObject: GrStyleObject,
     side: string
   ) {
-    if (this._contentEl?.getAttribute('data-side') === side) {
+    if (this.contentEl?.getAttribute('data-side') === side) {
       GrAnnotation.annotateElement(
-        this._contentEl,
+        this.contentEl,
         offset,
         length,
-        styleObject.getClassName(this._contentEl)
+        styleObject.getClassName(this.contentEl)
       );
     }
   }
@@ -93,8 +95,8 @@
    * @param side The side of the update. ('left' or 'right')
    */
   annotateLineNumber(styleObject: GrStyleObject, side: string) {
-    if (this._lineNumberEl?.classList.contains(side)) {
-      styleObject.apply(this._lineNumberEl);
+    if (this.lineNumberEl?.classList.contains(side)) {
+      styleObject.apply(this.lineNumberEl);
     }
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
index e069f8b..a3d038d 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
@@ -16,83 +16,42 @@
  */
 import {GrAnnotationActionsContext} from './gr-annotation-actions-context';
 import {GrDiffLine} from '../../diff/gr-diff/gr-diff-line';
-import {
-  CoverageRange,
-  DiffLayer,
-  DiffLayerListener,
-} from '../../../types/types';
+import {DiffLayer, DiffLayerListener} from '../../../types/types';
 import {Side} from '../../../constants/constants';
-import {PluginApi} from '../../plugins/gr-plugin-types';
-import {ChangeInfo, NumericChangeId} from '../../../types/common';
+import {EventType, PluginApi} from '../../../api/plugin';
+import {appContext} from '../../../services/app-context';
+import {
+  AnnotationCallback,
+  AnnotationPluginApi,
+  CoverageProvider,
+} from '../../../api/annotation';
 
-type AddLayerFunc = (ctx: GrAnnotationActionsContext) => void;
-
-type NotifyFunc = (
-  path: string,
-  start: number,
-  end: number,
-  side: Side
-) => void;
-
-export type CoverageProvider = (
-  changeNum: NumericChangeId,
-  path: string,
-  basePatchNum?: number,
-  patchNum?: number,
-  change?: ChangeInfo
-) => Promise<Array<CoverageRange>>;
-
-export class GrAnnotationActionsInterface {
-  // Collect all annotation layers instantiated by getLayer. Will be used when
-  // notifying their listeners in the notify function.
+export class GrAnnotationActionsInterface implements AnnotationPluginApi {
+  /**
+   * Collect all annotation layers instantiated by createLayer. This is only
+   * used for being able to look up the appropriate layer when notify() is
+   * being called by plugins.
+   */
   private annotationLayers: AnnotationLayer[] = [];
 
-  private coverageProvider: CoverageProvider | null = null;
+  private coverageProvider?: CoverageProvider;
 
-  // Default impl is a no-op.
-  private addLayerFunc: AddLayerFunc = () => {};
+  private annotationCallback?: AnnotationCallback;
+
+  private readonly reporting = appContext.reportingService;
 
   constructor(private readonly plugin: PluginApi) {
-    // Return this instance when there is an annotatediff event.
-    plugin.on('annotatediff', this);
+    plugin.on(EventType.ANNOTATE_DIFF, this);
   }
 
-  /**
-   * Register a function to call to apply annotations. Plugins should use
-   * GrAnnotationActionsContext.annotateRange and
-   * GrAnnotationActionsContext.annotateLineNumber to apply a CSS class to the
-   * line content or the line number.
-   *
-   * @param addLayerFunc The function
-   * that will be called when the AnnotationLayer is ready to annotate.
-   */
-  addLayer(addLayerFunc: AddLayerFunc) {
-    this.addLayerFunc = addLayerFunc;
+  setLayer(annotationCallback: AnnotationCallback) {
+    if (this.annotationCallback) {
+      console.warn('Overwriting an existing plugin annotation layer.');
+    }
+    this.annotationCallback = annotationCallback;
     return this;
   }
 
-  /**
-   * The specified function will be called with a notify function for the plugin
-   * to call when it has all required data for annotation. Optional.
-   *
-   * @param notifyFunc See doc of the notify function below to see what it does.
-   */
-  addNotifier(notifyFunc: (n: NotifyFunc) => void) {
-    notifyFunc(
-      (path: string, startRange: number, endRange: number, side: Side) =>
-        this.notify(path, startRange, endRange, side)
-    );
-    return this;
-  }
-
-  /**
-   * The specified function will be called when a gr-diff component is built,
-   * and feeds the returned coverage data into the diff. Optional.
-   *
-   * Be sure to call this only once and only from one plugin. Multiple coverage
-   * providers are not supported. A second call will just overwrite the
-   * provider of the first call.
-   */
   setCoverageProvider(
     coverageProvider: CoverageProvider
   ): GrAnnotationActionsInterface {
@@ -111,35 +70,20 @@
     return this.coverageProvider;
   }
 
-  /**
-   * Returns a checkbox HTMLElement that can be used to toggle annotations
-   * on/off. The checkbox will be initially disabled. Plugins should enable it
-   * when data is ready and should add a click handler to toggle CSS on/off.
-   *
-   * Note1: Calling this method from multiple plugins will only work for the
-   * 1st call. It will print an error message for all subsequent calls
-   * and will not invoke their onAttached functions.
-   * Note2: This method will be deprecated and eventually removed when
-   * https://bugs.chromium.org/p/gerrit/issues/detail?id=8077 is
-   * implemented.
-   *
-   * @param checkboxLabel Will be used as the label for the checkbox.
-   * Optional. "Enable" is used if this is not specified.
-   * @param onAttached The function that will be called
-   * when the checkbox is attached to the page.
-   */
   enableToggleCheckbox(
     checkboxLabel: string,
     onAttached: (checkboxEl: Element | null) => void
   ) {
     this.plugin.hook('annotation-toggler').onAttached(element => {
       if (!element.content) {
-        console.error('plugin endpoint without content.');
+        this.reporting.error(new Error('plugin endpoint without content.'));
         return;
       }
       if (!element.content.hidden) {
-        console.error(
-          element.content.id + ' is already enabled. Cannot re-enable.'
+        this.reporting.error(
+          new Error(
+            `${element.content.id} is already enabled. Cannot re-enable.`
+          )
         );
         return;
       }
@@ -159,16 +103,6 @@
     return this;
   }
 
-  /**
-   * The notify function will call the listeners of all required annotation
-   * layers. Intended to be called by the plugin when all required data for
-   * annotation is available.
-   *
-   * @param path The file path whose listeners should be notified.
-   * @param start The line where the update starts.
-   * @param end The line where the update ends.
-   * @param side The side of the update ('left' or 'right').
-   */
   notify(path: string, start: number, end: number, side: Side) {
     for (const annotationLayer of this.annotationLayers) {
       // Notify only the annotation layer that is associated with the specified
@@ -180,24 +114,21 @@
   }
 
   /**
-   * Should be called to register annotation layers by the framework. Not
-   * intended to be called by plugins.
+   * Factory method called by Gerrit for creating a DiffLayer for each diff that
+   * is rendered.
    *
-   * Don't forget to dispose layer.
-   *
-   * @param path The file path (eg: /COMMIT_MSG').
-   * @param changeNum The Gerrit change number.
+   * Don't forget to also call disposeLayer().
    */
-  getLayer(path: string, changeNum: number) {
-    const annotationLayer = new AnnotationLayer(
-      path,
-      changeNum,
-      this.addLayerFunc
-    );
+  createLayer(path: string, changeNum: number) {
+    const callbackFn = this.annotationCallback || (() => {});
+    const annotationLayer = new AnnotationLayer(path, changeNum, callbackFn);
     this.annotationLayers.push(annotationLayer);
     return annotationLayer;
   }
 
+  /**
+   * Called by Gerrit for each diff renderer that had called createLayer().
+   */
   disposeLayer(path: string) {
     this.annotationLayers = this.annotationLayers.filter(
       annotationLayer => annotationLayer.path !== path
@@ -205,6 +136,10 @@
   }
 }
 
+/**
+ * An AnnotationLayer exists for each file that is being rendered. This class is
+ * not exposed to plugins, but being used by Gerrit's diff rendering.
+ */
 export class AnnotationLayer implements DiffLayer {
   private listeners: DiffLayerListener[] = [];
 
@@ -213,13 +148,13 @@
    *
    * @param path The file path (eg: /COMMIT_MSG').
    * @param changeNum The Gerrit change number.
-   * @param addLayerFunc The function
+   * @param annotationCallback The function
    * that will be called when the AnnotationLayer is ready to annotate.
    */
   constructor(
     readonly path: string,
     private readonly changeNum: number,
-    private readonly addLayerFunc: AddLayerFunc
+    private readonly annotationCallback: AnnotationCallback
   ) {
     this.listeners = [];
   }
@@ -241,7 +176,8 @@
   }
 
   /**
-   * Layer method to add annotations to a line.
+   * Called by Gerrit during diff rendering for each line. Delegates to the
+   * plugin provided callback for potentially annotating this line.
    *
    * @param contentEl The DIV.contentText element of the line
    * content to apply the annotation to using annotateRange.
@@ -254,18 +190,20 @@
     lineNumberEl: HTMLElement,
     line: GrDiffLine
   ) {
-    const annotationActionsContext = new GrAnnotationActionsContext(
+    const context = new GrAnnotationActionsContext(
       contentEl,
       lineNumberEl,
       line,
       this.path,
       this.changeNum
     );
-    this.addLayerFunc(annotationActionsContext);
+    this.annotationCallback(context);
   }
 
   /**
-   * Notify Layer listeners of changes to annotations.
+   * Notify layer listeners (which typically is just Gerrit's diff renderer) of
+   * changes to annotations after the diff rendering had already completed. This
+   * is indirectly called by plugins using the AnnotationPluginApi.notify().
    *
    * @param start The line where the update starts.
    * @param end The line where the update ends.
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js
index 8b3f501..9811f99 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js
@@ -59,9 +59,9 @@
       assert.equal(context.line, line);
       assert.equal(context.changeNum, changeNum);
     };
-    annotationActions.addLayer(testLayerFunc);
+    annotationActions.setLayer(testLayerFunc);
 
-    const annotationLayer = annotationActions.getLayer(
+    const annotationLayer = annotationActions.createLayer(
         '/dummy/path', changeNum);
 
     const lineNumberEl = document.createElement('td');
@@ -72,27 +72,19 @@
   test('add notifier', () => {
     const path1 = '/dummy/path1';
     const path2 = '/dummy/path2';
-    const annotationLayer1 = annotationActions.getLayer(path1, 1);
-    const annotationLayer2 = annotationActions.getLayer(path2, 1);
+    annotationActions.setLayer(context => {});
+    const annotationLayer1 = annotationActions.createLayer(path1, 1);
+    const annotationLayer2 = annotationActions.createLayer(path2, 1);
     const layer1Spy = sinon.spy(annotationLayer1, 'notifyListeners');
     const layer2Spy = sinon.spy(annotationLayer2, 'notifyListeners');
 
-    let notify;
-    let notifyFuncCalled;
-    const notifyFunc = n => {
-      notifyFuncCalled = true;
-      notify = n;
-    };
-    annotationActions.addNotifier(notifyFunc);
-    assert.isTrue(notifyFuncCalled);
-
     // Assert that no layers are invoked with a different path.
-    notify('/dummy/path3', 0, 10, 'right');
+    annotationActions.notify('/dummy/path3', 0, 10, 'right');
     assert.isFalse(layer1Spy.called);
     assert.isFalse(layer2Spy.called);
 
     // Assert that only the 1st layer is invoked with path1.
-    notify(path1, 0, 10, 'right');
+    annotationActions.notify(path1, 0, 10, 'right');
     assert.isTrue(layer1Spy.called);
     assert.isFalse(layer2Spy.called);
 
@@ -101,7 +93,7 @@
     layer2Spy.resetHistory();
 
     // Assert that only the 2nd layer is invoked with path2.
-    notify(path2, 0, 20, 'left');
+    annotationActions.notify(path2, 0, 20, 'left');
     assert.isFalse(layer1Spy.called);
     assert.isTrue(layer2Spy.called);
   });
@@ -133,12 +125,9 @@
     // Assert that error is shown if we try to enable checkbox again.
     onAttachedFuncCalled = false;
     annotationActions.enableToggleCheckbox('test label2', onAttachedFunc);
-    const errorStub = sinon.stub(
-        console, 'error').callsFake((msg, err) => undefined);
+    const errorStub = sinon.stub(annotationActions.reporting, 'error');
     emulateAttached();
-    assert.isTrue(
-        errorStub.calledWith(
-            'annotation-span is already enabled. Cannot re-enable.'));
+    assert.isTrue(errorStub.called);
     // Assert that onAttachedFunc is not called and the label has not changed.
     assert.isFalse(onAttachedFuncCalled);
     assert.equal(document.getElementById('annotation-label').textContent,
@@ -146,7 +135,8 @@
   });
 
   test('layer notify listeners', () => {
-    const annotationLayer = annotationActions.getLayer('/dummy/path', 1);
+    annotationActions.setLayer(context => {});
+    const annotationLayer = annotationActions.createLayer('/dummy/path', 1);
     let listenerCalledTimes = 0;
     const startRange = 10;
     const endRange = 20;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts
index 8f743e9..979b86e 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts
@@ -16,23 +16,13 @@
  */
 
 import {getBaseUrl} from '../../../utils/url-util';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {HttpMethod} from '../../../constants/constants';
 import {RequestPayload} from '../../../types/common';
+import {appContext} from '../../../services/app-context';
 
 export const PRELOADED_PROTOCOL = 'preloaded:';
 export const PLUGIN_LOADING_TIMEOUT_MS = 10000;
 
-let _restAPI: RestApiService | undefined;
-export function getRestAPI() {
-  if (!_restAPI) {
-    _restAPI = (document.createElement(
-      'gr-rest-api-interface'
-    ) as unknown) as RestApiService;
-  }
-  return _restAPI;
-}
-
 /**
  * Retrieves the name of the plugin base on the url.
  */
@@ -83,7 +73,7 @@
   opt_callback?: (response: unknown) => void,
   opt_payload?: RequestPayload
 ) {
-  return getRestAPI()
+  return appContext.restApiService
     .send(method, url, opt_payload)
     .then(response => {
       if (response.status < 200 || response.status >= 300) {
@@ -95,7 +85,7 @@
           }
         });
       } else {
-        return getRestAPI().getResponseObject(response);
+        return appContext.restApiService.getResponseObject(response);
       }
     })
     .then(response => {
@@ -105,9 +95,3 @@
       return response;
     });
 }
-
-// TEST only methods / properties
-
-export function testOnly_resetInternalState() {
-  _restAPI = undefined;
-}
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..a4c6974 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
@@ -14,51 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {
-  ActionType,
-  ActionPriority,
-} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {JsApiService} from './gr-js-api-types';
-import {TargetElement} from '../../plugins/gr-plugin-types';
+import {PluginApi, TargetElement} from '../../../api/plugin';
 import {ActionInfo, RequireProperties} from '../../../types/common';
-
-interface Plugin {
-  getPluginName(): string;
-}
-
-export enum ChangeActions {
-  ABANDON = 'abandon',
-  DELETE = '/',
-  DELETE_EDIT = 'deleteEdit',
-  EDIT = 'edit',
-  FOLLOW_UP = 'followup',
-  IGNORE = 'ignore',
-  MOVE = 'move',
-  PRIVATE = 'private',
-  PRIVATE_DELETE = 'private.delete',
-  PUBLISH_EDIT = 'publishEdit',
-  REBASE = 'rebase',
-  REBASE_EDIT = 'rebaseEdit',
-  READY = 'ready',
-  RESTORE = 'restore',
-  REVERT = 'revert',
-  REVERT_SUBMISSION = 'revert_submission',
-  REVIEWED = 'reviewed',
-  STOP_EDIT = 'stopEdit',
-  SUBMIT = 'submit',
-  UNIGNORE = 'unignore',
-  UNREVIEWED = 'unreviewed',
-  WIP = 'wip',
-}
-
-export enum RevisionActions {
-  CHERRYPICK = 'cherrypick',
-  REBASE = 'rebase',
-  SUBMIT = 'submit',
-  DOWNLOAD = 'download',
-}
-
-export type PrimaryActionKey = ChangeActions | RevisionActions;
+import {appContext} from '../../../services/app-context';
+import {
+  ActionPriority,
+  ActionType,
+  ChangeActions,
+  ChangeActionsPluginApi,
+  PrimaryActionKey,
+  RevisionActions,
+} from '../../../api/change-actions';
 
 export interface UIActionInfo extends RequireProperties<ActionInfo, 'label'> {
   __key: string;
@@ -93,8 +59,8 @@
   getActionDetails(actionName: string): ActionInfo | undefined;
 }
 
-export class GrChangeActionsInterface {
-  private _el?: GrChangeActionsElement;
+export class GrChangeActionsInterface implements ChangeActionsPluginApi {
+  private el?: GrChangeActionsElement;
 
   RevisionActions = RevisionActions;
 
@@ -102,7 +68,7 @@
 
   ActionType = ActionType;
 
-  constructor(public plugin: Plugin, el?: GrChangeActionsElement) {
+  constructor(public plugin: PluginApi, el?: GrChangeActionsElement) {
     this.setEl(el);
   }
 
@@ -114,7 +80,7 @@
       console.warn('changeActions() is not ready');
       return;
     }
-    this._el = el;
+    this.el = el;
   }
 
   /**
@@ -122,17 +88,15 @@
    * element and retrieve if the interface was created before element.
    */
   private ensureEl(): GrChangeActionsElement {
-    if (!this._el) {
-      const sharedApiElement = (document.createElement(
-        'gr-js-api-interface'
-      ) as unknown) as JsApiService;
+    if (!this.el) {
+      const sharedApiElement = appContext.jsApiService;
       this.setEl(
         (sharedApiElement.getElement(
           TargetElement.CHANGE_ACTIONS
         ) as unknown) as GrChangeActionsElement
       );
     }
-    return this._el!;
+    return this.el!;
   }
 
   addPrimaryActionKey(key: PrimaryActionKey) {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.ts
index 7069304..effebe1 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.ts
@@ -15,26 +15,21 @@
  * limitations under the License.
  */
 
-import {GrReplyDialog} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {PluginApi, TargetElement} from '../../plugins/gr-plugin-types';
+import {GrReplyDialog} from '../../../services/gr-rest-api/gr-rest-api';
+import {PluginApi, TargetElement} from '../../../api/plugin';
 import {JsApiService} from './gr-js-api-types';
-
-// TODO(TS): maybe move interfaces\types to other files when convertion complete
-interface LabelsChangedDetail {
-  name: string;
-  value: string;
-}
-interface ValueChangedDetail {
-  value: string;
-}
-
-type ReplyChangedCallback = (text: string) => void;
-type LabelsChangedCallback = (detail: LabelsChangedDetail) => void;
+import {
+  ChangeReplyPluginApi,
+  LabelsChangedCallback,
+  LabelsChangedDetail,
+  ReplyChangedCallback,
+  ValueChangedDetail,
+} from '../../../api/change-reply';
 
 /**
  * GrChangeReplyInterface, provides a set of handy methods on reply dialog.
  */
-export class GrChangeReplyInterface {
+export class GrChangeReplyInterface implements ChangeReplyPluginApi {
   constructor(
     readonly plugin: PluginApi,
     readonly sharedApiElement: JsApiService
@@ -46,7 +41,7 @@
     ) as unknown) as GrReplyDialog;
   }
 
-  getLabelValue(label: string) {
+  getLabelValue(label: string): string {
     return this._el.getLabelValue(label);
   }
 
@@ -100,6 +95,6 @@
   }
 
   showMessage(message: string) {
-    return this._el.setPluginMessage(message);
+    this._el.setPluginMessage(message);
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js
index 8f41b39..52efc56 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import '../../change/gr-reply-dialog/gr-reply-dialog.js';
 import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-reply-dialog');
 
@@ -30,10 +31,8 @@
   let plugin;
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-      getAccount() { return Promise.resolve(null); },
-    });
+    stubRestApi('getConfig').returns(Promise.resolve({}));
+    stubRestApi('getAccount').returns(Promise.resolve(null));
   });
 
   suite('early init', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
index 37ac354..7f9218a 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
@@ -24,9 +24,9 @@
   PluginOptionMap,
   PluginLoader,
 } from './gr-plugin-loader';
-import {getRestAPI, send} from './gr-api-utils';
+import {send} from './gr-api-utils';
 import {appContext} from '../../../services/app-context';
-import {PluginApi} from '../../plugins/gr-plugin-types';
+import {PluginApi} from '../../../api/plugin';
 import {HttpMethod} from '../../../constants/constants';
 import {RequestPayload} from '../../../types/common';
 import {
@@ -118,7 +118,7 @@
   callback?: (response: Response) => void
 ) {
   console.warn('.delete() is deprecated! Use plugin.restApi().delete()');
-  return getRestAPI()
+  return appContext.restApiService
     .send(HttpMethod.DELETE, url)
     .then(response => {
       if (response.status !== 204) {
@@ -168,7 +168,7 @@
       'Gerrit.getLoggedIn() is deprecated! ' +
         'Use plugin.restApi().getLoggedIn()'
     );
-    return document.createElement('gr-rest-api-interface').getLoggedIn();
+    return appContext.restApiService.getLoggedIn();
   };
 
   globalGerritObj.get = (
@@ -250,7 +250,7 @@
    *   });
    * });
    *
-   * // Listen on your-special-event from pluignB
+   * // Listen on your-special-event from pluginB
    * Gerrit.install(pluginB => {
    *   Gerrit.on("your-special-event", ({plugin}) => {
    *     // do something, plugin is pluginA
@@ -259,8 +259,10 @@
    */
   globalGerritObj.addListener = (eventName: string, cb: EventCallback) =>
     eventEmitter.addListener(eventName, cb);
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   globalGerritObj.dispatch = (eventName: string, detail: any) =>
     eventEmitter.dispatch(eventName, detail);
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   globalGerritObj.emit = (eventName: string, detail: any) =>
     eventEmitter.emit(eventName, detail);
   globalGerritObj.off = (eventName: string, cb: EventCallback) =>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js
index 9312f1b..608e711 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js
@@ -16,12 +16,11 @@
  */
 
 import '../../../test/common-test-setup-karma.js';
-import './gr-js-api-interface.js';
 import {getPluginLoader} from './gr-plugin-loader.js';
 import {resetPlugins} from '../../../test/test-utils.js';
 import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
-
-const basicFixture = fixtureFromElement('gr-js-api-interface');
+import {stubRestApi} from '../../../test/test-utils.js';
+import {appContext} from '../../../services/app-context.js';
 
 const pluginApi = _testOnly_initGerritPluginApi();
 
@@ -29,21 +28,13 @@
   let element;
 
   let clock;
-  let sendStub;
 
   setup(() => {
     clock = sinon.useFakeTimers();
 
-    sendStub = sinon.stub().returns(Promise.resolve({status: 200}));
-    stub('gr-rest-api-interface', {
-      getAccount() {
-        return Promise.resolve({name: 'Judy Hopps'});
-      },
-      send(...args) {
-        return sendStub(...args);
-      },
-    });
-    element = basicFixture.instantiate();
+    stubRestApi('getAccount').returns(Promise.resolve({name: 'Judy Hopps'}));
+    stubRestApi('send').returns(Promise.resolve({status: 200}));
+    element = appContext.jsApiService;
   });
 
   teardown(() => {
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 736fac9..8689ad2 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
@@ -14,36 +14,34 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {getPluginLoader} from './gr-plugin-loader';
-import {patchNumEquals} from '../../../utils/patch-set-util';
-import {customElement} from '@polymer/decorators';
+import {hasOwnProperty} from '../../../utils/common-util';
 import {
   ChangeInfo,
   LabelNameToValuesMap,
+  ReviewInput,
   RevisionInfo,
 } from '../../../types/common';
 import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api';
-import {GrAdminApi, MenuLink} from '../../plugins/gr-admin-api/gr-admin-api';
+import {GrAdminApi} from '../../plugins/gr-admin-api/gr-admin-api';
 import {
   JsApiService,
   EventCallback,
   ShowChangeDetail,
   ShowRevisionActionsDetail,
 } from './gr-js-api-types';
-import {EventType, TargetElement} from '../../plugins/gr-plugin-types';
-import {DiffLayer, HighlightJS} from '../../../types/types';
-import {ParsedChangeInfo} from '../gr-rest-api-interface/gr-reviewer-updates-parser';
+import {EventType, TargetElement} from '../../../api/plugin';
+import {DiffLayer, HighlightJS, ParsedChangeInfo} from '../../../types/types';
+import {appContext} from '../../../services/app-context';
+import {MenuLink} from '../../../api/admin';
 
 const elements: {[key: string]: HTMLElement} = {};
 const eventCallbacks: {[key: string]: EventCallback[]} = {};
 
-@customElement('gr-js-api-interface')
-export class GrJsApiInterface
-  extends GestureEventListeners(LegacyElementMixin(PolymerElement))
-  implements JsApiService {
+export class GrJsApiInterface implements JsApiService {
+  private readonly reporting = appContext.reportingService;
+
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   handleEvent(type: EventType, detail: any) {
     getPluginLoader()
       .awaitPluginsLoaded()
@@ -98,7 +96,7 @@
       try {
         return callback(change, revision) === false;
       } catch (err) {
-        console.error(err);
+        this.reporting.error(err);
       }
       return false;
     });
@@ -119,7 +117,7 @@
       try {
         cb(detail.path);
       } catch (err) {
-        console.error(err);
+        this.reporting.error(err);
       }
     }
   }
@@ -150,7 +148,7 @@
 
     let revision;
     for (const rev of Object.values(change.revisions || {})) {
-      if (patchNumEquals(rev._number, patchNum)) {
+      if (rev._number === patchNum) {
         revision = rev;
         break;
       }
@@ -160,7 +158,7 @@
       try {
         cb(change, revision, info);
       } catch (err) {
-        console.error(err);
+        this.reporting.error(err);
       }
     }
   }
@@ -173,7 +171,7 @@
       try {
         cb(detail.revisionActions, detail.change);
       } catch (err) {
-        console.error(err);
+        this.reporting.error(err);
       }
     }
   }
@@ -183,7 +181,7 @@
       try {
         cb(change, msg);
       } catch (err) {
-        console.error(err);
+        this.reporting.error(err);
       }
     }
   }
@@ -194,7 +192,7 @@
       try {
         cb(detail.node);
       } catch (err) {
-        console.error(err);
+        this.reporting.error(err);
       }
     }
   }
@@ -204,7 +202,7 @@
       try {
         cb(detail.change);
       } catch (err) {
-        console.error(err);
+        this.reporting.error(err);
       }
     }
   }
@@ -214,7 +212,7 @@
       try {
         cb(detail.hljs);
       } catch (err) {
-        console.error(err);
+        this.reporting.error(err);
       }
     }
   }
@@ -224,7 +222,7 @@
       try {
         revertMsg = cb(change, revertMsg, origMsg) as string;
       } catch (err) {
-        console.error(err);
+        this.reporting.error(err);
       }
     }
     return revertMsg;
@@ -243,7 +241,7 @@
           origMsg
         ) as string;
       } catch (err) {
-        console.error(err);
+        this.reporting.error(err);
       }
     }
     return revertSubmissionMsg;
@@ -254,10 +252,10 @@
     for (const cb of this._getEventCallbacks(EventType.ANNOTATE_DIFF)) {
       const annotationApi = (cb as unknown) as GrAnnotationActionsInterface;
       try {
-        const layer = annotationApi.getLayer(path, changeNum);
-        layers.push(layer);
+        const layer = annotationApi.createLayer(path, changeNum);
+        if (layer) layers.push(layer);
       } catch (err) {
-        console.error(err);
+        this.reporting.error(err);
       }
     }
     return layers;
@@ -269,7 +267,7 @@
         const annotationApi = (cb as unknown) as GrAnnotationActionsInterface;
         annotationApi.disposeLayer(path);
       } catch (err) {
-        console.error(err);
+        this.reporting.error(err);
       }
     }
   }
@@ -304,25 +302,24 @@
     return links;
   }
 
-  getLabelValuesPostRevert(change?: ChangeInfo): LabelNameToValuesMap {
-    let labels: LabelNameToValuesMap = {};
+  getReviewPostRevert(change?: ChangeInfo): ReviewInput {
+    let review: ReviewInput = {};
     for (const cb of this._getEventCallbacks(EventType.POST_REVERT)) {
       try {
-        labels = cb(change);
+        const r = cb(change);
+        if (hasOwnProperty(r, 'labels')) {
+          review = r as ReviewInput;
+        } else {
+          review = {labels: r as LabelNameToValuesMap};
+        }
       } catch (err) {
-        console.error(err);
+        this.reporting.error(err);
       }
     }
-    return labels;
+    return review;
   }
 
   _getEventCallbacks(type: EventType) {
     return eventCallbacks[type] || [];
   }
 }
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-js-api-interface': JsApiService & Element;
-  }
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.ts
index b9a6ff4..a05e7e1 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.ts
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../gr-rest-api-interface/gr-rest-api-interface';
 import './gr-js-api-interface-element';
 import './gr-public-js-api';
 import './gr-gerrit';
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
index b5c4e48..12a4056 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
@@ -19,17 +19,18 @@
 import './gr-js-api-interface.js';
 import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface.js';
 import {GrSettingsApi} from '../../plugins/gr-settings-api/gr-settings-api.js';
-import {EventType} from '../../plugins/gr-plugin-types.js';
+import {EventType} from '../../../api/plugin.js';
 import {PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils.js';
 import {getPluginLoader} from './gr-plugin-loader.js';
 import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
 import {stubBaseUrl} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-js-api-interface');
+import sinon from 'sinon/pkg/sinon-esm';
+import {stubRestApi} from '../../../test/test-utils.js';
+import {appContext} from '../../../services/app-context.js';
 
 const pluginApi = _testOnly_initGerritPluginApi();
 
-suite('gr-js-api-interface tests', () => {
+suite('GrJsApiInterface tests', () => {
   let element;
   let plugin;
   let errorStub;
@@ -45,19 +46,12 @@
   setup(() => {
     clock = sinon.useFakeTimers();
 
-    getResponseObjectStub = sinon.stub().returns(Promise.resolve());
-    sendStub = sinon.stub().returns(Promise.resolve({status: 200}));
-    stub('gr-rest-api-interface', {
-      getAccount() {
-        return Promise.resolve({name: 'Judy Hopps'});
-      },
-      getResponseObject: getResponseObjectStub,
-      send(...args) {
-        return sendStub(...args);
-      },
-    });
-    element = basicFixture.instantiate();
-    errorStub = sinon.stub(console, 'error');
+    stubRestApi('getAccount').returns(Promise.resolve({name: 'Judy Hopps'}));
+    getResponseObjectStub = stubRestApi('getResponseObject').returns(
+        Promise.resolve());
+    sendStub = stubRestApi('send').returns(Promise.resolve({status: 200}));
+    element = appContext.jsApiService;
+    errorStub = sinon.stub(element.reporting, 'error');
     pluginApi.install(p => { plugin = p; }, '0.1',
         'http://test.com/plugins/testplugin/static/test.js');
     getPluginLoader().loadPlugins([]);
@@ -288,18 +282,33 @@
     assert.isTrue(errorStub.calledTwice);
   });
 
-  test('postrevert event', () => {
+  test('postrevert event labels', () => {
     function getLabels(c) {
       return {'Code-Review': 1};
     }
 
-    assert.deepEqual(element.getLabelValuesPostRevert(null), {});
+    assert.deepEqual(element.getReviewPostRevert(null), {});
     assert.equal(errorStub.callCount, 0);
 
     plugin.on(EventType.POST_REVERT, throwErrFn);
     plugin.on(EventType.POST_REVERT, getLabels);
     assert.deepEqual(
-        element.getLabelValuesPostRevert(null), {'Code-Review': 1});
+        element.getReviewPostRevert(null), {labels: {'Code-Review': 1}});
+    assert.isTrue(errorStub.calledOnce);
+  });
+
+  test('postrevert event review', () => {
+    function getReview(c) {
+      return {labels: {'Code-Review': 1}};
+    }
+
+    assert.deepEqual(element.getReviewPostRevert(null), {});
+    assert.equal(errorStub.callCount, 0);
+
+    plugin.on(EventType.POST_REVERT, throwErrFn);
+    plugin.on(EventType.POST_REVERT, getReview);
+    assert.deepEqual(
+        element.getReviewPostRevert(null), {labels: {'Code-Review': 1}});
     assert.isTrue(errorStub.calledOnce);
   });
 
@@ -404,6 +413,7 @@
 
   suite('popup', () => {
     test('popup(element) is deprecated', () => {
+      sinon.stub(console, 'error');
       plugin.popup(document.createElement('div'));
       assert.isTrue(console.error.calledOnce);
     });
@@ -416,7 +426,7 @@
             // eslint-disable-next-line no-invalid-this
             const grPopupInterface = this;
             assert.equal(grPopupInterface.plugin, plugin);
-            assert.equal(grPopupInterface._moduleName, 'some-name');
+            assert.equal(grPopupInterface.moduleName, 'some-name');
           });
       plugin.popup('some-name');
       assert.isTrue(openStub.calledOnce);
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 6456370..0b28c5e 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
@@ -14,11 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {ActionInfo, ChangeInfo, PatchSetNum} from '../../../types/common';
-import {EventType, TargetElement} from '../../plugins/gr-plugin-types';
-import {DiffLayer} from '../../../types/types';
+import {
+  ActionInfo,
+  ChangeInfo,
+  PatchSetNum,
+  ReviewInput,
+  RevisionInfo,
+} from '../../../types/common';
+import {EventType, TargetElement} from '../../../api/plugin';
+import {DiffLayer, ParsedChangeInfo} from '../../../types/types';
 import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api';
-import {MenuLink} from '../../plugins/gr-admin-api/gr-admin-api';
+import {MenuLink} from '../../../api/admin';
 
 export interface ShowChangeDetail {
   change: ChangeInfo;
@@ -31,6 +37,7 @@
   revisionActions: {[key: string]: ActionInfo};
 }
 
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
 export type EventCallback = (...args: any[]) => any;
 
 export interface JsApiService {
@@ -41,6 +48,7 @@
     revertSubmissionMsg: string,
     origMsg: string
   ): string;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   handleEvent(eventName: EventType, detail: any): void;
   modifyRevertMsg(
     change: ChangeInfo,
@@ -52,5 +60,7 @@
   disposeDiffLayers(path: string): void;
   getCoverageAnnotationApis(): Promise<GrAnnotationActionsInterface[]>;
   getAdminMenuLinks(): MenuLink[];
-  // TODO(TS): Add more methods when needed for the TS conversion.
+  handleCommitMessage(change: ChangeInfo | ParsedChangeInfo, msg: string): void;
+  canSubmitChange(change: ChangeInfo, revision?: RevisionInfo | null): boolean;
+  getReviewPostRevert(change?: ChangeInfo): ReviewInput;
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
index ffdf710..2135c30 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
@@ -16,19 +16,19 @@
  */
 
 import {RevisionInfo, ChangeInfo, RequestPayload} from '../../../types/common';
-import {PluginApi} from '../../plugins/gr-plugin-types';
+import {ShowAlertEventDetail} from '../../../types/events';
+import {PluginApi} from '../../../api/plugin';
 import {UIActionInfo} from './gr-change-actions-js-api';
-
-interface GrPopupInterface {
-  close(): void;
-}
+import {windowLocationReload} from '../../../utils/dom-util';
+import {PopupPluginApi} from '../../../api/popup';
+import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface';
 
 interface ButtonCallBacks {
   onclick: (event: Event) => boolean;
 }
 
 export class GrPluginActionContext {
-  private _popups: GrPopupInterface[] = [];
+  private popups: PopupPluginApi[] = [];
 
   constructor(
     public readonly plugin: PluginApi,
@@ -39,24 +39,24 @@
 
   popup(element: Node) {
     this.plugin.popup().then(popApi => {
-      const popupEl = popApi._getElement();
+      const popupEl = (popApi as GrPopupInterface)._getElement();
       if (!popupEl) {
         throw new Error('Popup element not found');
       }
       popupEl.appendChild(element);
-      this._popups.push(popApi);
+      this.popups.push(popApi);
     });
   }
 
   hide() {
-    for (const popupApi of this._popups) {
+    for (const popupApi of this.popups) {
       popupApi.close();
     }
-    this._popups.splice(0);
+    this.popups.splice(0);
   }
 
   refresh() {
-    window.location.reload();
+    windowLocationReload();
   }
 
   textfield(): HTMLElement {
@@ -117,7 +117,7 @@
       .then(onSuccess)
       .catch((error: unknown) => {
         document.dispatchEvent(
-          new CustomEvent('show-alert', {
+          new CustomEvent<ShowAlertEventDetail>('show-alert', {
             detail: {
               message: `Plugin network error: ${error}`,
             },
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
index e764bf8..34c976a 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
@@ -19,6 +19,7 @@
 import './gr-js-api-interface.js';
 import {GrPluginActionContext} from './gr-plugin-action-context.js';
 import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
+import {addListenerForTest} from '../../../test/test-utils.js';
 
 const pluginApi = _testOnly_initGerritPluginApi();
 
@@ -137,7 +138,7 @@
       send: sendStub,
     });
     const errorStub = sinon.stub();
-    document.addEventListener('show-alert', errorStub);
+    addListenerForTest(document, 'show-alert', errorStub);
     instance.call();
     await flush();
     assert.isTrue(errorStub.calledOnce);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
index 3935ef1..82df2fa 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
@@ -16,9 +16,11 @@
  */
 
 import {importHref} from '../../../scripts/import-href';
-import {HookApi, PluginApi} from '../../plugins/gr-plugin-types';
+import {PluginApi} from '../../../api/plugin';
 import {notUndefined} from '../../../types/types';
+import {HookApi} from '../../../api/hook';
 
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
 type Callback = (value: any) => void;
 
 export interface ModuleInfo {
@@ -42,16 +44,17 @@
 export class GrPluginEndpoints {
   private readonly _endpoints = new Map<string, ModuleInfo[]>();
 
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   private readonly _callbacks = new Map<string, ((value: any) => void)[]>();
 
   private readonly _dynamicPlugins = new Map<string, Set<string>>();
 
   private readonly _importedUrls = new Set<string>();
 
-  private _pluginLoaded = false;
+  private pluginLoaded = false;
 
   setPluginsReady() {
-    this._pluginLoaded = true;
+    this.pluginLoaded = true;
   }
 
   onNewEndpoint(endpoint: string, callback: Callback) {
@@ -122,7 +125,7 @@
     // one register before plugins ready
     // the other done after, then only the later one will have the callbacks
     // invoked.
-    if (this._pluginLoaded && this._callbacks.has(endpoint)) {
+    if (this.pluginLoaded && this._callbacks.has(endpoint)) {
       this._callbacks.get(endpoint)!.forEach(callback => callback(moduleInfo));
     }
   }
@@ -178,6 +181,7 @@
   }
 
   importUrl(pluginUrl: URL) {
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
     let timerId: any;
     return Promise.race([
       new Promise((resolve, reject) => {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
index 47b7be3..db34e5a 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
@@ -24,9 +24,10 @@
 import {Plugin} from './gr-public-js-api';
 import {getBaseUrl} from '../../../utils/url-util';
 import {getPluginEndpoints} from './gr-plugin-endpoints';
-import {PluginApi} from '../../plugins/gr-plugin-types';
+import {PluginApi} from '../../../api/plugin';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {hasOwnProperty} from '../../../utils/common-util';
+import {ShowAlertEventDetail} from '../../../types/events';
 
 enum PluginState {
   /** State that indicates the plugin is pending to be loaded. */
@@ -73,7 +74,7 @@
 const UNKNOWN_PLUGIN_PREFIX = '__$$__';
 
 // Current API version for Plugin,
-// plugins with incompatible version will not be laoded.
+// plugins with incompatible version will not be loaded.
 const API_VERSION = '0.1';
 
 /**
@@ -143,7 +144,6 @@
     });
 
     this.awaitPluginsLoaded().then(() => {
-      console.info('Plugins loaded');
       this._getReporting().pluginsLoaded(this._getAllInstalledPluginNames());
     });
   }
@@ -249,7 +249,7 @@
   _failToLoad(message: string, pluginUrl?: string) {
     // Show an alert with the error
     document.dispatchEvent(
-      new CustomEvent('show-alert', {
+      new CustomEvent<ShowAlertEventDetail>('show-alert', {
         detail: {
           message: `Plugin install error: ${message} from ${pluginUrl}`,
         },
@@ -340,6 +340,7 @@
     const urlWithAP = this._urlFor(pluginUrl, window.ASSETS_PATH);
     const urlWithoutAP = this._urlFor(pluginUrl);
     let onerror = undefined;
+    this._getReporting().reportExecution('html-plugin', {pluginUrl});
     if (urlWithAP !== urlWithoutAP) {
       onerror = () => this._loadHtmlPlugin(urlWithoutAP, opts.sync);
     }
@@ -424,14 +425,15 @@
       return Promise.resolve();
     }
     if (!this._loadingPromise) {
-      // TODO(TS): Should be a number, but TS thinks that is must be some weird
-      // NodeJS.Timeout object.
-      let timerId: any;
+      // specify window here so that TS pulls the correct setTimeout method
+      // if window is not specified, then the function is pulled from node
+      // and the return type is NodeJS.Timeout object
+      let timerId: number;
       this._loadingPromise = Promise.race([
-        new Promise(resolve => (this._loadingResolver = resolve)),
+        new Promise<void>(resolve => (this._loadingResolver = resolve)),
         new Promise(
           (_, reject) =>
-            (timerId = setTimeout(() => {
+            (timerId = window.setTimeout(() => {
               reject(new Error(this._timeout()));
             }, PLUGIN_LOADING_TIMEOUT_MS))
         ),
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
index 584cd39..f5b1fca 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
@@ -16,14 +16,12 @@
  */
 
 import '../../../test/common-test-setup-karma.js';
-import './gr-js-api-interface.js';
 import {PRELOADED_PROTOCOL, PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils.js';
 import {_testOnly_resetPluginLoader} from './gr-plugin-loader.js';
 import {resetPlugins, stubBaseUrl} from '../../../test/test-utils.js';
 import {_testOnly_flushPreinstalls} from './gr-gerrit.js';
 import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
-
-const basicFixture = fixtureFromElement('gr-js-api-interface');
+import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
 
 const pluginApi = _testOnly_initGerritPluginApi();
 
@@ -31,25 +29,16 @@
   let plugin;
 
   let url;
-  let sendStub;
   let pluginLoader;
   let clock;
 
   setup(() => {
     clock = sinon.useFakeTimers();
 
-    sendStub = sinon.stub().returns(Promise.resolve({status: 200}));
-    stub('gr-rest-api-interface', {
-      getAccount() {
-        return Promise.resolve({name: 'Judy Hopps'});
-      },
-      send(...args) {
-        return sendStub(...args);
-      },
-    });
+    stubRestApi('getAccount').returns(Promise.resolve({name: 'Judy Hopps'}));
+    stubRestApi('send').returns(Promise.resolve({status: 200}));
     pluginLoader = _testOnly_resetPluginLoader();
     sinon.stub(document.body, 'appendChild');
-    basicFixture.instantiate();
     url = window.location.origin;
   });
 
@@ -156,7 +145,7 @@
     ];
 
     const alertStub = sinon.stub();
-    document.addEventListener('show-alert', alertStub);
+    addListenerForTest(document, 'show-alert', alertStub);
 
     sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
       pluginApi.install(() => {
@@ -184,7 +173,7 @@
     ];
 
     const alertStub = sinon.stub();
-    document.addEventListener('show-alert', alertStub);
+    addListenerForTest(document, 'show-alert', alertStub);
 
     sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
       pluginApi.install(() => {
@@ -217,7 +206,7 @@
     ];
 
     const alertStub = sinon.stub();
-    document.addEventListener('show-alert', alertStub);
+    addListenerForTest(document, 'show-alert', alertStub);
 
     sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
       pluginApi.install(() => {
@@ -236,14 +225,14 @@
     assert.isTrue(alertStub.calledTwice);
   });
 
-  test('plugins installed failed becasue of wrong version', async () => {
+  test('plugins installed failed because of wrong version', async () => {
     const plugins = [
       'http://test.com/plugins/foo/static/test.js',
       'http://test.com/plugins/bar/static/test.js',
     ];
 
     const alertStub = sinon.stub();
-    document.addEventListener('show-alert', alertStub);
+    addListenerForTest(document, 'show-alert', alertStub);
 
     sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
       pluginApi.install(() => {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
index 00b2963..cd35d4e 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
@@ -14,57 +14,56 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {
-  ErrorCallback,
-  RestApiService,
-} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {HttpMethod} from '../../../constants/constants';
 import {RequestPayload} from '../../../types/common';
+import {appContext} from '../../../services/app-context';
+import {ErrorCallback, RestPluginApi} from '../../../api/rest';
 
-let restApi: RestApiService | null = null;
-
-export function _testOnlyResetRestApi() {
-  restApi = null;
+async function getErrorMessage(response: Response): Promise<string> {
+  const text = await response.text();
+  return text || `${response.status}`;
 }
 
-function getRestApi(): RestApiService {
-  if (!restApi) {
-    restApi = (document.createElement(
-      'gr-rest-api-interface'
-    ) as unknown) as RestApiService;
+// This is an internal error, that must never be visible outside of this
+// file. It is used only inside GrPluginRestApi.send method. See detailed
+// explanation in the GrPluginRestApi.send method.
+class ResponseError extends Error {
+  public constructor(readonly response: Response) {
+    super();
   }
-  return restApi;
 }
 
-export class GrPluginRestApi {
+export class GrPluginRestApi implements RestPluginApi {
+  private readonly restApi = appContext.restApiService;
+
   constructor(private readonly prefix = '') {}
 
   getLoggedIn() {
-    return getRestApi().getLoggedIn();
+    return this.restApi.getLoggedIn();
   }
 
   getVersion() {
-    return getRestApi().getVersion();
+    return this.restApi.getVersion();
   }
 
   getConfig() {
-    return getRestApi().getConfig();
+    return this.restApi.getConfig();
   }
 
   invalidateReposCache() {
-    getRestApi().invalidateReposCache();
+    this.restApi.invalidateReposCache();
   }
 
   getAccount() {
-    return getRestApi().getAccount();
+    return this.restApi.getAccount();
   }
 
   getAccountCapabilities(capabilities: string[]) {
-    return getRestApi().getAccountCapabilities(capabilities);
+    return this.restApi.getAccountCapabilities(capabilities);
   }
 
   getRepos(filter: string, reposPerPage: number, offset?: number) {
-    return getRestApi().getRepos(filter, reposPerPage, offset);
+    return this.restApi.getRepos(filter, reposPerPage, offset);
   }
 
   fetch(
@@ -101,7 +100,7 @@
     errFn?: ErrorCallback,
     contentType?: string
   ): Promise<Response | void> {
-    return getRestApi().send(
+    return this.restApi.send(
       method,
       this.prefix + url,
       payload,
@@ -120,27 +119,51 @@
     errFn?: ErrorCallback,
     contentType?: string
   ) {
-    return this.fetch(method, url, payload, errFn, contentType).then(
-      response => {
+    // Plugins typically don't want Gerrit to show error dialogs for failed
+    // requests. So we are defining a default errFn here, even if it is not
+    // explicitly set by the caller.
+    // TODO: We are soon getting rid of the `errFn` altogether. There are only
+    // 2 known usages of errFn in plugins: delete-project and verify-status.
+    errFn =
+      errFn ??
+      ((response: Response | null | undefined, error?: Error) => {
+        if (error) throw error;
+        // Some plugins show an error message if send is failed, smth like:
+        // pluginApi.send(...).catch(err => showError(err));
+        // The response can contain an error text, but getting this text is
+        // an asynchronous operation. At the same time, the errFn must be a
+        // synchronous function.
+        // As a workaround, we throw an ResponseError here and then catch
+        // it inside a catch block below and read the message.
+        if (response) throw new ResponseError(response);
+        throw new Error('Generic REST API error.');
+      });
+    return this.fetch(method, url, payload, errFn, contentType)
+      .then(response => {
+        // Will typically not happen. The response can only be unset, if the
+        // errFn handles the error and then returns void or undefined or null.
+        // But the errFn above always throws.
         if (!response) {
-          // TODO(TS): Fix method definition
-          // If errFn exists and doesn't throw an exception, the fetch method
-          // returns empty response
-          throw new Error('errFn must throw an exception');
+          throw new Error('plugin rest-api call failed');
         }
+        // Will typically not happen. errFn will have dealt with that and the
+        // caller will get a rejected promise already.
         if (response.status < 200 || response.status >= 300) {
-          return response.text().then(text => {
-            if (text) {
-              return Promise.reject(new Error(text));
-            } else {
-              return Promise.reject(new Error(`${response.status}`));
-            }
-          });
+          return getErrorMessage(response).then(msg =>
+            Promise.reject(new Error(msg))
+          );
         } else {
-          return getRestApi().getResponseObject(response);
+          return this.restApi.getResponseObject(response);
         }
-      }
-    );
+      })
+      .catch(err => {
+        if (err instanceof ResponseError) {
+          return getErrorMessage(err.response).then(msg => {
+            throw new Error(msg);
+          });
+        }
+        throw err;
+      });
   }
 
   get(url: string) {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.js
index 53aaa1e..d2b5658 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.js
@@ -19,31 +19,20 @@
 import './gr-js-api-interface.js';
 import {GrPluginRestApi} from './gr-plugin-rest-api.js';
 import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const pluginApi = _testOnly_initGerritPluginApi();
 
 suite('gr-plugin-rest-api tests', () => {
   let instance;
-
   let getResponseObjectStub;
   let sendStub;
-  let restApiStub;
 
   setup(() => {
-    getResponseObjectStub = sinon.stub().returns(Promise.resolve());
-    sendStub = sinon.stub().returns(Promise.resolve({status: 200}));
-    restApiStub = {
-      getAccount: () => Promise.resolve({name: 'Judy Hopps'}),
-      getResponseObject: getResponseObjectStub,
-      send: sendStub,
-      getLoggedIn: sinon.stub(),
-      getVersion: sinon.stub(),
-      getConfig: sinon.stub(),
-    };
-    stub('gr-rest-api-interface', Object.keys(restApiStub).reduce((a, k) => {
-      a[k] = (...args) => restApiStub[k](...args);
-      return a;
-    }, {}));
+    stubRestApi('getAccount').returns(Promise.resolve({name: 'Judy Hopps'}));
+    getResponseObjectStub = stubRestApi('getResponseObject').returns(
+        Promise.resolve());
+    sendStub = stubRestApi('send').returns(Promise.resolve({status: 200}));
     pluginApi.install(p => {}, '0.1',
         'http://test.com/plugins/testplugin/static/test.js');
     instance = new GrPluginRestApi();
@@ -119,25 +108,25 @@
   });
 
   test('getLoggedIn', () => {
-    restApiStub.getLoggedIn.returns(Promise.resolve(true));
+    const stub = stubRestApi('getLoggedIn').returns(Promise.resolve(true));
     return instance.getLoggedIn().then(result => {
-      assert.isTrue(restApiStub.getLoggedIn.calledOnce);
+      assert.isTrue(stub.calledOnce);
       assert.isTrue(result);
     });
   });
 
   test('getVersion', () => {
-    restApiStub.getVersion.returns(Promise.resolve('foo bar'));
+    const stub = stubRestApi('getVersion').returns(Promise.resolve('foo bar'));
     return instance.getVersion().then(result => {
-      assert.isTrue(restApiStub.getVersion.calledOnce);
+      assert.isTrue(stub.calledOnce);
       assert.equal(result, 'foo bar');
     });
   });
 
   test('getConfig', () => {
-    restApiStub.getConfig.returns(Promise.resolve('foo bar'));
+    const stub = stubRestApi('getConfig').returns(Promise.resolve('foo bar'));
     return instance.getConfig().then(result => {
-      assert.isTrue(restApiStub.getConfig.calledOnce);
+      assert.isTrue(stub.calledOnce);
       assert.equal(result, 'foo bar');
     });
   });
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..e7843af 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
@@ -16,7 +16,6 @@
  */
 
 import {getBaseUrl} from '../../../utils/url-util';
-import {getSharedApiEl} from '../../../utils/dom-util';
 import {GrAttributeHelper} from '../../plugins/gr-attribute-helper/gr-attribute-helper';
 import {GrChangeActionsInterface} from './gr-change-actions-js-api';
 import {GrChangeReplyInterface} from './gr-change-reply-js-api';
@@ -33,19 +32,28 @@
 import {GrStylesApi} from '../../plugins/gr-styles-api/gr-styles-api';
 import {getPluginEndpoints} from './gr-plugin-endpoints';
 
-import {PRELOADED_PROTOCOL, getPluginNameFromUrl, send} from './gr-api-utils';
-import {GrReporintJsApi} from './gr-reporting-js-api';
-import {
-  EventType,
-  HookApi,
-  PluginApi,
-  RegisterOptions,
-  TargetElement,
-} from '../../plugins/gr-plugin-types';
+import {getPluginNameFromUrl, PRELOADED_PROTOCOL, send} from './gr-api-utils';
+import {GrReportingJsApi} from './gr-reporting-js-api';
+import {EventType, PluginApi, TargetElement} from '../../../api/plugin';
 import {RequestPayload} from '../../../types/common';
 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';
+import {appContext} from '../../../services/app-context';
+import {AdminPluginApi} from '../../../api/admin';
+import {AnnotationPluginApi} from '../../../api/annotation';
+import {StylesPluginApi} from '../../../api/styles';
+import {ThemePluginApi} from '../../../api/theme';
+import {EventHelperPluginApi} from '../../../api/event-helper';
+import {PopupPluginApi} from '../../../api/popup';
+import {SettingsPluginApi} from '../../../api/settings';
+import {ReportingPluginApi} from '../../../api/reporting';
+import {ChangeActionsPluginApi} from '../../../api/change-actions';
+import {ChangeMetadataPluginApi} from '../../../api/change-metadata';
+import {RepoPluginApi} from '../../../api/repo';
+import {ChangeReplyPluginApi} from '../../../api/change-reply';
+import {RestPluginApi} from '../../../api/rest';
+import {HookApi, RegisterOptions} from '../../../api/hook';
 
 /**
  * Plugin-provided custom components can affect content in extension
@@ -70,16 +78,14 @@
 export class Plugin implements PluginApi {
   readonly _url?: URL;
 
-  private _domHooks: GrDomHooksManager;
+  private domHooks: GrDomHooksManager;
 
   private readonly _name: string = PLUGIN_NAME_NOT_SET;
 
-  // TODO(TS): Change type to GrJsApiInterface
-  private readonly sharedApiElement: JsApiService;
+  private readonly jsApi = appContext.jsApiService;
 
   constructor(url?: string) {
-    this.sharedApiElement = getSharedApiEl();
-    this._domHooks = new GrDomHooksManager(this);
+    this.domHooks = new GrDomHooksManager(this);
 
     if (!url) {
       console.warn(
@@ -145,7 +151,7 @@
     const type =
       options && options.replace ? EndpointType.REPLACE : EndpointType.DECORATE;
     const slot = (options && options.slot) || '';
-    const domHook = this._domHooks.getDomHook(endpoint, moduleName);
+    const domHook = this.domHooks.getDomHook(endpoint, moduleName);
     moduleName = moduleName || domHook.getModuleName();
     getPluginEndpoints().registerModule(this, {
       slot,
@@ -167,11 +173,12 @@
   }
 
   getServerInfo() {
-    return document.createElement('gr-rest-api-interface').getConfig();
+    return appContext.restApiService.getConfig();
   }
 
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   on(eventName: EventType, callback: (...args: any[]) => any) {
-    this.sharedApiElement.addEventCallback(eventName, callback);
+    this.jsApi.addEventCallback(eventName, callback);
   }
 
   url(path?: string) {
@@ -233,59 +240,56 @@
       });
   }
 
-  annotationApi() {
+  annotationApi(): AnnotationPluginApi {
     return new GrAnnotationActionsInterface(this);
   }
 
-  changeActions() {
+  changeActions(): ChangeActionsPluginApi {
     return new GrChangeActionsInterface(
       this,
-      (this.sharedApiElement.getElement(
+      (this.jsApi.getElement(
         TargetElement.CHANGE_ACTIONS
       ) as unknown) as GrChangeActions
     );
   }
 
-  changeReply() {
-    return new GrChangeReplyInterface(this, this.sharedApiElement);
+  changeReply(): ChangeReplyPluginApi {
+    return new GrChangeReplyInterface(this, this.jsApi);
   }
 
-  reporting() {
-    return new GrReporintJsApi(this);
+  checks(): GrChecksApi {
+    return new GrChecksApi(this);
   }
 
-  theme() {
+  reporting(): ReportingPluginApi {
+    return new GrReportingJsApi(this);
+  }
+
+  theme(): ThemePluginApi {
     return new GrThemeApi(this);
   }
 
-  project() {
+  project(): RepoPluginApi {
     return new GrRepoApi(this);
   }
 
-  changeMetadata() {
+  changeMetadata(): ChangeMetadataPluginApi {
     return new GrChangeMetadataApi(this);
   }
 
-  admin() {
+  admin(): AdminPluginApi {
     return new GrAdminApi(this);
   }
 
-  settings() {
+  settings(): SettingsPluginApi {
     return new GrSettingsApi(this);
   }
 
-  styles() {
+  styles(): StylesPluginApi {
     return new GrStylesApi();
   }
 
-  /**
-   * To make REST requests for plugin-provided endpoints, use
-   *
-   * @example
-   * const pluginRestApi = plugin.restApi(plugin.url());
-   * @param prefix url for subsequent .get(), .post() etc requests.
-   */
-  restApi(prefix?: string) {
+  restApi(prefix?: string): RestPluginApi {
     return new GrPluginRestApi(prefix);
   }
 
@@ -293,15 +297,15 @@
     return new GrAttributeHelper(element);
   }
 
-  eventHelper(element: HTMLElement) {
+  eventHelper(element: HTMLElement): EventHelperPluginApi {
     return new GrEventHelper(element);
   }
 
-  popup(): Promise<GrPopupInterface>;
+  popup(): Promise<PopupPluginApi>;
 
-  popup(moduleName: string): Promise<GrPopupInterface>;
+  popup(moduleName: string): Promise<PopupPluginApi>;
 
-  popup(moduleName?: string): Promise<GrPopupInterface | null> {
+  popup(moduleName?: string): Promise<PopupPluginApi | null> {
     if (moduleName !== undefined && typeof moduleName !== 'string') {
       console.error('.popup(element) deprecated, use .popup(moduleName)!');
       return Promise.resolve(null);
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..d4b51a8 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
@@ -16,30 +16,26 @@
  */
 
 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 '../../../api/plugin';
+import {EventDetails, ReportingPluginApi} from '../../../api/reporting';
 
 /**
  * Defines all methods that will be exported to plugin from reporting service.
  */
-export class GrReporintJsApi {
+export class GrReportingJsApi implements ReportingPluginApi {
   private readonly reporting = appContext.reportingService;
 
   constructor(private readonly plugin: PluginApi) {}
 
   reportInteraction(eventName: string, details?: EventDetails) {
-    return this.reporting.reportInteraction(
+    this.reporting.reportInteraction(
       `${this.plugin.getPluginName()}-${eventName}`,
       details
     );
   }
 
   reportLifeCycle(eventName: string, details?: EventDetails) {
-    return this.reporting.reportLifeCycle(
+    this.reporting.reportLifeCycle(
       `${this.plugin.getPluginName()}-${eventName}`,
       details
     );
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.js
index 1229641..9e3876b 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.js
@@ -19,6 +19,7 @@
 import '../../change/gr-reply-dialog/gr-reply-dialog.js';
 import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
 import {appContext} from '../../../services/app-context.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const pluginApi = _testOnly_initGerritPluginApi();
 
@@ -27,10 +28,8 @@
   let plugin;
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-      getAccount() { return Promise.resolve(null); },
-    });
+    stubRestApi('getConfig').returns(Promise.resolve({}));
+    stubRestApi('getAccount').returns(Promise.resolve(null));
   });
 
   suite('early init', () => {
@@ -72,4 +71,4 @@
       );
     });
   });
-});
\ No newline at end of file
+});
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 1dac371..60a07a4 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
@@ -21,7 +21,6 @@
 import '../gr-button/gr-button';
 import '../gr-icons/gr-icons';
 import '../gr-label/gr-label';
-import '../gr-rest-api-interface/gr-rest-api-interface';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
@@ -38,15 +37,9 @@
   isQuickLabelInfo,
   isDetailedLabelInfo,
 } from '../../../types/common';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GrButton} from '../gr-button/gr-button';
 import {getVotingRangeOrDefault} from '../../../utils/label-util';
-
-export interface GrLabelInfo {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
+import {appContext} from '../../../services/app-context';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -90,6 +83,8 @@
   @property({type: Boolean})
   mutable = false;
 
+  private readonly restApiService = appContext.restApiService;
+
   // TODO(TS): not used, remove later
   _xhrPromise?: Promise<void>;
 
@@ -206,7 +201,7 @@
     const accountID = Number(
       `${target.getAttribute('data-account-id')}`
     ) as AccountId;
-    this._xhrPromise = this.$.restAPI
+    this._xhrPromise = this.restApiService
       .deleteVote(this.change._number, accountID, this.label)
       .then(response => {
         target.disabled = false;
@@ -228,7 +223,7 @@
     if (
       !labelInfo ||
       !isDetailedLabelInfo(labelInfo) ||
-      !labelInfo.values[score]
+      !labelInfo.values?.[score]
     ) {
       return '';
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
index 3955cd4..c640d23 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
@@ -31,9 +31,9 @@
       display: flex;
       justify-content: center;
       margin-right: var(--spacing-s);
-      padding: 0;
+      padding: 1px;
       @apply --vote-chip-styles;
-      border-width: 0;
+      border: 1px solid var(--border-color);
     }
     .max {
       background-color: var(--vote-color-approved);
@@ -68,7 +68,7 @@
       color: var(--border-color);
     }
     gr-account-link {
-      --account-max-length: 120px;
+      --account-max-length: 100px;
       margin-right: var(--spacing-xs);
     }
     iron-icon {
@@ -95,7 +95,7 @@
           <gr-label
             has-tooltip=""
             title="[[_computeValueTooltip(labelInfo, mappedLabel.value)]]"
-            class$="[[mappedLabel.className]] voteChip"
+            class$="[[mappedLabel.className]] voteChip font-small"
           >
             [[mappedLabel.value]]
           </gr-label>
@@ -118,5 +118,4 @@
       </tr>
     </template>
   </table>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js
index 3a2cc39..b2365aa 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-label-info.js';
 import {isHidden} from '../../../test/test-utils.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-label-info');
 
@@ -72,8 +73,7 @@
 
     test('deletes votes', () => {
       const deleteResponse = Promise.resolve({ok: true});
-      const deleteStub = sinon.stub(
-          element.$.restAPI, 'deleteVote').returns(deleteResponse);
+      const deleteStub = stubRestApi('deleteVote').returns(deleteResponse);
 
       element.change.removable_reviewers = [element.account];
       element.change.labels.test.recommended = {_account_id: 1};
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.ts b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.ts
index ad97d02..655acde 100644
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.ts
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.ts
@@ -15,14 +15,9 @@
  * limitations under the License.
  */
 import '../gr-js-api-interface/gr-js-api-interface';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-lib-loader_html';
-import {EventType} from '../../plugins/gr-plugin-types';
-import {customElement, property} from '@polymer/decorators';
-import {JsApiService} from '../gr-js-api-interface/gr-js-api-types';
+import {EventType} from '../../../api/plugin';
 import {HighlightJS} from '../../../types/types';
+import {appContext} from '../../../services/app-context';
 
 // preloaded in PolyGerritIndexHtml.soy
 const HLJS_PATH = 'bower_components/highlightjs/highlight.min.js';
@@ -35,21 +30,9 @@
   callbacks: HljsCallback[];
 }
 
-export interface GrLibLoader {
-  $: {
-    jsAPI: JsApiService & Element;
-  };
-}
-@customElement('gr-lib-loader')
-export class GrLibLoader extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrLibLoader {
+  private readonly jsAPI = appContext.jsApiService;
 
-  // NOTE: intended singleton.
-  @property({type: Object})
   _hljsState: HljsState = {
     configured: false,
     loading: false,
@@ -87,7 +70,7 @@
   _onHLJSLibLoaded() {
     const lib = this._getHighlightLib();
     this._hljsState.loading = false;
-    this.$.jsAPI.handleEvent(EventType.HIGHLIGHTJS_LOADED, {
+    this.jsAPI.handleEvent(EventType.HIGHLIGHTJS_LOADED, {
       hljs: lib,
     });
     for (const cb of this._hljsState.callbacks) {
@@ -152,9 +135,3 @@
     return root + HLJS_PATH;
   }
 }
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-lib-loader': GrLibLoader;
-  }
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_html.ts b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_html.ts
deleted file mode 100644
index f34f99e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_html.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.js b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.js
index 1ce175f..c89ff8e 100644
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.js
@@ -17,23 +17,22 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-lib-loader.js';
-
-const basicFixture = fixtureFromElement('gr-lib-loader');
+import {GrLibLoader} from './gr-lib-loader.js';
 
 suite('gr-lib-loader tests', () => {
-  let element;
+  let grLibLoader;
   let resolveLoad;
   let loadStub;
 
   setup(() => {
-    element = basicFixture.instantiate();
+    grLibLoader = new GrLibLoader();
 
-    loadStub = sinon.stub(element, '_loadScript').callsFake(() =>
+    loadStub = sinon.stub(grLibLoader, '_loadScript').callsFake(() =>
       new Promise(resolve => resolveLoad = resolve)
     );
 
     // Assert preconditions:
-    assert.isFalse(element._hljsState.loading);
+    assert.isFalse(grLibLoader._hljsState.loading);
   });
 
   teardown(() => {
@@ -42,26 +41,26 @@
     }
 
     // Because the element state is a singleton, clean it up.
-    element._hljsState.configured = false;
-    element._hljsState.loading = false;
-    element._hljsState.callbacks = [];
+    grLibLoader._hljsState.configured = false;
+    grLibLoader._hljsState.loading = false;
+    grLibLoader._hljsState.callbacks = [];
   });
 
   test('only load once', async () => {
-    sinon.stub(element, '_getHLJSUrl').returns('');
+    sinon.stub(grLibLoader, '_getHLJSUrl').returns('');
     const firstCallHandler = sinon.stub();
-    element.getHLJS().then(firstCallHandler);
+    grLibLoader.getHLJS().then(firstCallHandler);
 
     // It should now be in the loading state.
     assert.isTrue(loadStub.called);
-    assert.isTrue(element._hljsState.loading);
+    assert.isTrue(grLibLoader._hljsState.loading);
     assert.isFalse(firstCallHandler.called);
 
     const secondCallHandler = sinon.stub();
-    element.getHLJS().then(secondCallHandler);
+    grLibLoader.getHLJS().then(secondCallHandler);
 
     // No change in state.
-    assert.isTrue(element._hljsState.loading);
+    assert.isTrue(grLibLoader._hljsState.loading);
     assert.isFalse(firstCallHandler.called);
     assert.isFalse(secondCallHandler.called);
 
@@ -69,7 +68,7 @@
     resolveLoad();
     await flush();
     // The state should be loaded and both handlers called.
-    assert.isFalse(element._hljsState.loading);
+    assert.isFalse(grLibLoader._hljsState.loading);
     assert.isTrue(firstCallHandler.called);
     assert.isTrue(secondCallHandler.called);
   });
@@ -90,13 +89,13 @@
 
     test('returns hljs', async () => {
       const firstCallHandler = sinon.stub();
-      element.getHLJS().then(firstCallHandler);
+      grLibLoader.getHLJS().then(firstCallHandler);
       await flush();
       assert.isTrue(firstCallHandler.called);
       assert.isTrue(firstCallHandler.calledWith(hljsStub));
     });
 
-    test('configures hljs', () => element.getHLJS().then(() => {
+    test('configures hljs', () => grLibLoader.getHLJS().then(() => {
       assert.isTrue(window.hljs.configure.calledOnce);
     }));
   });
@@ -106,16 +105,16 @@
       let root;
 
       setup(() => {
-        sinon.stub(element, '_getLibRoot').callsFake(() => root);
+        sinon.stub(grLibLoader, '_getLibRoot').callsFake(() => root);
       });
 
       test('with no root', () => {
-        assert.isNull(element._getHLJSUrl());
+        assert.isNull(grLibLoader._getHLJSUrl());
       });
 
       test('with root', () => {
         root = 'test-root.com/';
-        assert.equal(element._getHLJSUrl(),
+        assert.equal(grLibLoader._getHLJSUrl(),
             'test-root.com/bower_components/highlightjs/highlight.min.js');
       });
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
index dbb8725..fa244f6 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
@@ -24,6 +24,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {customElement, property} from '@polymer/decorators';
 import {htmlTemplate} from './gr-linked-chip_html';
+import {fireEvent} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -64,11 +65,6 @@
 
   _handleRemoveTap(e: Event) {
     e.preventDefault();
-    this.dispatchEvent(
-      new CustomEvent('remove', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'remove');
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_html.ts b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_html.ts
index a335db7..8581a0c 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_html.ts
@@ -46,7 +46,6 @@
     gr-button.remove:focus {
       --gr-button: {
         @apply --gr-remove-button-style;
-        color: #333;
       }
     }
     gr-button.remove {
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts
index 9066911..36f518b 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts
@@ -350,27 +350,27 @@
     // The outputArray is used to store all of the matches found for all
     // patterns.
     const outputArray: CommentLinkItem[] = [];
-    for (const p in config) {
+    for (const [configName, linkInfo] of Object.entries(config)) {
       // TODO(TS): it seems, the following line can be rewritten as:
       // if(enabled === false || enabled === 0 || enabled === '')
       // Should be double-checked before update
       // eslint-disable-next-line eqeqeq
-      if (config[p].enabled != null && config[p].enabled == false) {
+      if (linkInfo.enabled != null && linkInfo.enabled == false) {
         continue;
       }
       // PolyGerrit doesn't use hash-based navigation like the GWT UI.
       // Account for this.
-      const html = config[p].html;
-      const link = config[p].link;
+      const html = linkInfo.html;
+      const link = linkInfo.link;
       if (html) {
-        config[p].html = html.replace(/<a href="#\//g, '<a href="/');
+        linkInfo.html = html.replace(/<a href="#\//g, '<a href="/');
       } else if (link) {
         if (link[0] === '#') {
-          config[p].link = link.substr(1);
+          linkInfo.link = link.substr(1);
         }
       }
 
-      const pattern = new RegExp(config[p].match, 'g');
+      const pattern = new RegExp(linkInfo.match, 'g');
 
       let match;
       let textToCheck = text;
@@ -382,10 +382,10 @@
           pattern,
           // Either html or link has a value. Otherwise an exception is thrown
           // in the code below.
-          (config[p].html || config[p].link)!
+          (linkInfo.html || linkInfo.link)!
         );
 
-        if (config[p].html) {
+        if (linkInfo.html) {
           let i;
           // Skip portion of replacement string that is equal to original to
           // allow overlapping patterns.
@@ -402,7 +402,7 @@
             match[0].length - i,
             outputArray
           );
-        } else if (config[p].link) {
+        } else if (linkInfo.link) {
           this.addLink(
             match[0],
             result,
@@ -413,7 +413,7 @@
         } else {
           throw Error(
             'linkconfig entry ' +
-              p +
+              configName +
               ' doesn’t contain a link or html attribute.'
           );
         }
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..bf532ef 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,8 @@
 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';
+import {fireEvent} from '../../../utils/event-util';
 
 const REQUEST_DEBOUNCE_INTERVAL_MS = 200;
 
@@ -34,6 +35,8 @@
   }
 }
 
+const DEBOUNCER_RELOAD = 'reload';
+
 @customElement('gr-list-view')
 class GrListView extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
@@ -51,7 +54,7 @@
   @property({type: Number})
   itemsPerPage = 25;
 
-  @property({type: String})
+  @property({type: String, observer: '_filterChanged'})
   filter?: string;
 
   @property({type: Number})
@@ -66,11 +69,11 @@
   /** @override */
   detached() {
     super.detached();
-    this.cancelDebouncer('reload');
+    this.cancelDebouncer(DEBOUNCER_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,9 +81,9 @@
     this._debounceReload(newFilter);
   }
 
-  _debounceReload(filter: string) {
+  _debounceReload(filter?: string) {
     this.debounce(
-      'reload',
+      DEBOUNCER_RELOAD,
       () => {
         if (this.path) {
           if (filter) {
@@ -96,12 +99,7 @@
   }
 
   _createNewItem() {
-    this.dispatchEvent(
-      new CustomEvent('create-clicked', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'create-clicked');
   }
 
   _computeNavLink(
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..20e5296 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
@@ -20,8 +20,10 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-overlay_html';
 import {IronOverlayMixin} from '../../../mixins/iron-overlay-mixin/iron-overlay-mixin';
-import {customElement, property} from '@polymer/decorators';
+import {customElement} from '@polymer/decorators';
 import {IronOverlayBehavior} from '@polymer/iron-overlay-behavior/iron-overlay-behavior';
+import {findActiveElement} from '../../../utils/dom-util';
+import {fireEvent} from '../../../utils/event-util';
 
 const AWAIT_MAX_ITERS = 10;
 const AWAIT_STEP = 5;
@@ -54,12 +56,13 @@
    * @event fullscreen-overlay-opened
    */
 
-  @property({type: Boolean})
-  private _fullScreenOpen = false;
+  private fullScreenOpen = false;
 
   private _boundHandleClose: () => void = () => super.close();
 
-  private focusableNodes: Node[] | undefined;
+  private focusableNodes?: Node[];
+
+  private returnFocusTo?: HTMLElement;
 
   get _focusableNodes() {
     if (this.focusableNodes) {
@@ -76,6 +79,7 @@
     // once the type contains the exported member,
     // should replace with:
     // import {IronFocusablesHelper} from '@polymer/iron-overlay-behavior/iron-focusables-helper';
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
     return (window.Polymer as any).IronFocusablesHelper.getTabbableNodes(this);
   }
 
@@ -89,17 +93,13 @@
   }
 
   open() {
+    this.returnFocusTo = findActiveElement(document, true) ?? undefined;
     window.addEventListener('popstate', this._boundHandleClose);
-    return new Promise((resolve, reject) => {
+    return new Promise<void>((resolve, reject) => {
       super.open.apply(this);
       if (this._isMobile()) {
-        this.dispatchEvent(
-          new CustomEvent('fullscreen-overlay-opened', {
-            composed: true,
-            bubbles: true,
-          })
-        );
-        this._fullScreenOpen = true;
+        fireEvent(this, 'fullscreen-overlay-opened');
+        this.fullScreenOpen = true;
       }
       this._awaitOpen(resolve, reject);
     });
@@ -112,14 +112,13 @@
   // called after iron-overlay is closed. Does not actually close the overlay
   _overlayClosed() {
     window.removeEventListener('popstate', this._boundHandleClose);
-    if (this._fullScreenOpen) {
-      this.dispatchEvent(
-        new CustomEvent('fullscreen-overlay-closed', {
-          composed: true,
-          bubbles: true,
-        })
-      );
-      this._fullScreenOpen = false;
+    if (this.fullScreenOpen) {
+      fireEvent(this, 'fullscreen-overlay-closed');
+      this.fullScreenOpen = false;
+    }
+    if (this.returnFocusTo) {
+      this.returnFocusTo.focus();
+      this.returnFocusTo = undefined;
     }
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.js b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.js
index 4b6ae34..72c3399 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.js
@@ -57,11 +57,11 @@
     await element.open();
 
     assert.isTrue(element._isMobile.called);
-    assert.isTrue(element._fullScreenOpen);
+    assert.isTrue(element.fullScreenOpen);
     assert.isTrue(openHandler.called);
 
     element._overlayClosed();
-    assert.isFalse(element._fullScreenOpen);
+    assert.isFalse(element.fullScreenOpen);
     assert.isTrue(closeHandler.called);
   });
 
@@ -75,11 +75,11 @@
     await element.open();
 
     assert.isTrue(element._isMobile.called);
-    assert.isFalse(element._fullScreenOpen);
+    assert.isFalse(element.fullScreenOpen);
     assert.isFalse(openHandler.called);
 
     element._overlayClosed();
-    assert.isFalse(element._fullScreenOpen);
+    assert.isFalse(element.fullScreenOpen);
     assert.isFalse(closeHandler.called);
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_html.ts b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_html.ts
index facc4f8..a9d9216 100644
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_html.ts
@@ -36,7 +36,7 @@
       }
     }
   </style>
-  <nav id="nav">
+  <nav id="nav" aria-label="Sidebar">
     <slot></slot>
   </nav>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts
index 01eada8..6db1925 100644
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts
@@ -18,7 +18,6 @@
 import '../../../styles/shared-styles';
 import '../gr-icons/gr-icons';
 import '../gr-labeled-autocomplete/gr-labeled-autocomplete';
-import '../gr-rest-api-interface/gr-rest-api-interface';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
@@ -33,7 +32,7 @@
   BranchInfo,
 } from '../../../types/common';
 import {GrLabeledAutocomplete} from '../gr-labeled-autocomplete/gr-labeled-autocomplete';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {appContext} from '../../../services/app-context';
 
 const SUGGESTIONS_LIMIT = 15;
 const REF_PREFIX = 'refs/heads/';
@@ -42,7 +41,6 @@
   $: {
     repoInput: GrLabeledAutocomplete;
     branchInput: GrLabeledAutocomplete;
-    restAPI: RestApiService & Element;
   };
 }
 @customElement('gr-repo-branch-picker')
@@ -68,6 +66,8 @@
   @property({type: Object})
   _repoQuery?: AutocompleteQuery;
 
+  private readonly restApiService = appContext.restApiService;
+
   constructor() {
     super();
     this._query = input => this._getRepoBranchesSuggestions(input);
@@ -95,13 +95,13 @@
     if (input.startsWith(REF_PREFIX)) {
       input = input.substring(REF_PREFIX.length);
     }
-    return this.$.restAPI
+    return this.restApiService
       .getRepoBranches(input, this.repo, SUGGESTIONS_LIMIT)
       .then(res => this._branchResponseToSuggestions(res));
   }
 
   _getRepoSuggestions(input: string) {
-    return this.$.restAPI
+    return this.restApiService
       .getRepos(input, SUGGESTIONS_LIMIT)
       .then(res => this._repoResponseToSuggestions(res));
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_html.ts b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_html.ts
index 934b3cb..3e551b6 100644
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_html.ts
@@ -49,5 +49,4 @@
     >
     </gr-labeled-autocomplete>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.js b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.js
index 1d8ae98..4f12edc 100644
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-repo-branch-picker.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-repo-branch-picker');
 
@@ -28,8 +29,9 @@
   });
 
   suite('_getRepoSuggestions', () => {
+    let getReposStub;
     setup(() => {
-      sinon.stub(element.$.restAPI, 'getRepos')
+      getReposStub = stubRestApi('getRepos')
           .returns(Promise.resolve([
             {
               id: 'plugins%2Favatars-external',
@@ -47,25 +49,25 @@
           ]));
     });
 
-    test('converts to suggestion objects', () => {
+    test('converts to suggestion objects', async () => {
       const input = 'plugins/avatars';
-      return element._getRepoSuggestions(input).then(suggestions => {
-        assert.isTrue(element.$.restAPI.getRepos.calledWith(input));
-        const unencodedNames = [
-          'plugins/avatars-external',
-          'plugins/avatars-gravatar',
-          'plugins/avatars/external',
-          'plugins/avatars/gravatar',
-        ];
-        assert.deepEqual(suggestions.map(s => s.name), unencodedNames);
-        assert.deepEqual(suggestions.map(s => s.value), unencodedNames);
-      });
+      const suggestions = await element._getRepoSuggestions(input);
+      assert.isTrue(getReposStub.calledWith(input));
+      const unencodedNames = [
+        'plugins/avatars-external',
+        'plugins/avatars-gravatar',
+        'plugins/avatars/external',
+        'plugins/avatars/gravatar',
+      ];
+      assert.deepEqual(suggestions.map(s => s.name), unencodedNames);
+      assert.deepEqual(suggestions.map(s => s.value), unencodedNames);
     });
   });
 
   suite('_getRepoBranchesSuggestions', () => {
+    let getRepoBranchesStub;
     setup(() => {
-      sinon.stub(element.$.restAPI, 'getRepoBranches')
+      getRepoBranchesStub = stubRestApi('getRepoBranches')
           .returns(Promise.resolve([
             {ref: 'refs/heads/stable-2.10'},
             {ref: 'refs/heads/stable-2.11'},
@@ -76,48 +78,43 @@
           ]));
     });
 
-    test('converts to suggestion objects', () => {
+    test('converts to suggestion objects', async () => {
       const repo = 'gerrit';
       const branchInput = 'stable-2.1';
       element.repo = repo;
-      return element._getRepoBranchesSuggestions(branchInput)
-          .then(suggestions => {
-            assert.isTrue(element.$.restAPI.getRepoBranches.calledWith(
-                branchInput, repo, 15));
-            const refNames = [
-              'stable-2.10',
-              'stable-2.11',
-              'stable-2.12',
-              'stable-2.13',
-              'stable-2.14',
-              'stable-2.15',
-            ];
-            assert.deepEqual(suggestions.map(s => s.name), refNames);
-            assert.deepEqual(suggestions.map(s => s.value), refNames);
-          });
+      const suggestions =
+          await element._getRepoBranchesSuggestions(branchInput);
+      assert.isTrue(getRepoBranchesStub.calledWith(branchInput, repo, 15));
+      const refNames = [
+        'stable-2.10',
+        'stable-2.11',
+        'stable-2.12',
+        'stable-2.13',
+        'stable-2.14',
+        'stable-2.15',
+      ];
+      assert.deepEqual(suggestions.map(s => s.name), refNames);
+      assert.deepEqual(suggestions.map(s => s.value), refNames);
     });
 
-    test('filters out ref prefix', () => {
+    test('filters out ref prefix', async () => {
       const repo = 'gerrit';
       const branchInput = 'refs/heads/stable-2.1';
       element.repo = repo;
       return element._getRepoBranchesSuggestions(branchInput)
           .then(suggestions => {
-            assert.isTrue(element.$.restAPI.getRepoBranches.calledWith(
+            assert.isTrue(getRepoBranchesStub.calledWith(
                 'stable-2.1', repo, 15));
           });
     });
 
-    test('does not query when repo is unset', () => element
-        ._getRepoBranchesSuggestions('')
-        .then(() => {
-          assert.isFalse(element.$.restAPI.getRepoBranches.called);
-          element.repo = 'gerrit';
-          return element._getRepoBranchesSuggestions('');
-        })
-        .then(() => {
-          assert.isTrue(element.$.restAPI.getRepoBranches.called);
-        }));
+    test('does not query when repo is unset', async () => {
+      await element._getRepoBranchesSuggestions('');
+      assert.isFalse(getRepoBranchesStub.called);
+      element.repo = 'gerrit';
+      await element._getRepoBranchesSuggestions('');
+      assert.isTrue(getRepoBranchesStub.called);
+    });
   });
 });
 
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 b3596c7..ec59ddc 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
@@ -16,8 +16,6 @@
  */
 /* NB: Order is important, because of namespaced classes. */
 
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {GrEtagDecorator} from './gr-etag-decorator';
 import {
@@ -25,28 +23,23 @@
   FetchParams,
   FetchPromisesCache,
   GrRestApiHelper,
+  parsePrefixedJSON,
+  readResponsePayload,
   SendJSONRequest,
   SendRequest,
   SiteBasedCache,
 } from './gr-rest-apis/gr-rest-api-helper';
-import {
-  GrReviewerUpdatesParser,
-  ParsedChangeInfo,
-} from './gr-reviewer-updates-parser';
+import {GrReviewerUpdatesParser} from './gr-reviewer-updates-parser';
 import {parseDate} from '../../../utils/date-util';
 import {getBaseUrl} from '../../../utils/url-util';
 import {appContext} from '../../../services/app-context';
-import {
-  getParentIndex,
-  isMergeParent,
-  patchNumEquals,
-} from '../../../utils/patch-set-util';
+import {getParentIndex, isMergeParent} from '../../../utils/patch-set-util';
 import {
   ListChangesOption,
   listChangesOptionsToHex,
 } from '../../../utils/change-util';
 import {assertNever, hasOwnProperty} from '../../../utils/common-util';
-import {customElement, property} from '@polymer/decorators';
+import {customElement} from '@polymer/decorators';
 import {AuthRequestInit, AuthService} from '../../../services/gr-auth/gr-auth';
 import {
   AccountCapabilityInfo,
@@ -54,109 +47,115 @@
   AccountExternalIdInfo,
   AccountId,
   AccountInfo,
+  ActionNameToActionInfoMap,
   AssigneeInput,
   Base64File,
   Base64FileContent,
   Base64ImageFile,
+  BlameInfo,
   BranchInfo,
+  BranchInput,
   BranchName,
+  CapabilityInfoMap,
   ChangeId,
   ChangeInfo,
   ChangeMessageId,
+  ChangeViewChangeInfo,
   CommentInfo,
   CommentInput,
   CommitId,
   CommitInfo,
   ConfigInfo,
   ConfigInput,
+  ContributorAgreementInfo,
+  ContributorAgreementInput,
   DashboardId,
   DashboardInfo,
   DeleteDraftCommentsInput,
-  DiffInfo,
   DiffPreferenceInput,
-  DiffPreferencesInfo,
+  DocResult,
+  EditInfo,
   EditPatchSetNum,
   EditPreferencesInfo,
+  EmailAddress,
+  EmailInfo,
   EncodedGroupId,
+  FileNameToFileInfoMap,
+  FilePathToDiffInfoMap,
+  FixId,
   GitRef,
   GpgKeyId,
+  GpgKeyInfo,
+  GpgKeysInput,
+  GroupAuditEventInfo,
   GroupId,
   GroupInfo,
   GroupInput,
+  GroupName,
+  GroupNameToGroupInfoMap,
   GroupOptionsInput,
+  Hashtag,
   HashtagsInput,
   ImagesForDiff,
+  IncludedInInfo,
+  MergeableInfo,
   NameToProjectInfoMap,
+  NumericChangeId,
   ParentPatchSetNum,
   ParsedJSON,
+  Password,
   PatchRange,
   PatchSetNum,
   PathToCommentsInfoMap,
   PathToRobotCommentsInfoMap,
+  PluginInfo,
   PreferencesInfo,
   PreferencesInput,
+  ProjectAccessInfo,
   ProjectAccessInfoMap,
   ProjectAccessInput,
   ProjectInfo,
+  ProjectInfoWithName,
   ProjectInput,
   ProjectWatchInfo,
+  RelatedChangesInfo,
   RepoName,
+  RequestPayload,
   ReviewInput,
+  RevisionId,
   ServerInfo,
   SshKeyInfo,
-  UrlEncodedCommentId,
-  EditInfo,
-  FileNameToFileInfoMap,
-  SuggestedReviewerInfo,
-  GroupNameToGroupInfoMap,
-  GroupAuditEventInfo,
-  RequestPayload,
-  Password,
-  ContributorAgreementInput,
-  ContributorAgreementInfo,
-  BranchInput,
-  IncludedInInfo,
-  TagInput,
-  PluginInfo,
-  GpgKeyInfo,
-  GpgKeysInput,
-  DocResult,
-  EmailInfo,
-  ProjectAccessInfo,
-  CapabilityInfoMap,
-  ProjectInfoWithName,
-  TagInfo,
-  RelatedChangesInfo,
   SubmittedTogetherInfo,
-  NumericChangeId,
-  EmailAddress,
-  FixId,
-  FilePathToDiffInfoMap,
-  ChangeViewChangeInfo,
-  BlameInfo,
-  ActionNameToActionInfoMap,
-  RevisionId,
-  GroupName,
-  Hashtag,
+  SuggestedReviewerInfo,
+  TagInfo,
+  TagInput,
   TopMenuEntryInfo,
-  MergeableInfo,
+  UrlEncodedCommentId,
 } from '../../../types/common';
 import {
+  DiffInfo,
+  DiffPreferencesInfo,
+  IgnoreWhitespaceType,
+} from '../../../types/diff';
+import {
   CancelConditionCallback,
-  ErrorCallback,
-  RestApiService,
   GetDiffCommentsOutput,
   GetDiffRobotCommentsOutput,
-} from '../../../services/services/gr-rest-api/gr-rest-api';
+  RestApiService,
+} from '../../../services/gr-rest-api/gr-rest-api';
 import {
   CommentSide,
+  createDefaultDiffPrefs,
+  createDefaultEditPrefs,
+  createDefaultPreferences,
   DiffViewMode,
   HttpMethod,
-  IgnoreWhitespaceType,
   ReviewerState,
 } from '../../../constants/constants';
+import {firePageError, fireServerError} from '../../../utils/event-util';
+import {ParsedChangeInfo} from '../../../types/types';
+import {ErrorCallback} from '../../../api/rest';
 
-const JSON_PREFIX = ")]}'";
 const MAX_PROJECT_RESULTS = 25;
 // This value is somewhat arbitrary and not based on research or calculations.
 const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 850;
@@ -251,7 +250,6 @@
 
 interface GetDiffParams {
   [paramName: string]: string | undefined | null | number | boolean;
-  context?: number | 'ALL';
   intraline?: boolean | null;
   whitespace?: IgnoreWhitespaceType;
   parent?: number;
@@ -261,27 +259,6 @@
 type SendChangeRequest = SendRawChangeRequest | SendJSONChangeRequest;
 
 export function _testOnlyResetGrRestApiSharedObjects() {
-  // TODO(TS): The commented code below didn't do anything.
-  // It is impossible to reject an existing promise. Should be rewritten in a
-  // different way
-  // const fetchPromisesCacheData = fetchPromisesCache.testOnlyGetData();
-  // for (const key in fetchPromisesCacheData) {
-  //   if (hasOwnProperty(fetchPromisesCacheData, key)) {
-  //     // reject already fulfilled promise does nothing
-  //     fetchPromisesCacheData[key]!.reject();
-  //   }
-  // }
-  //
-  // for (const key in pendingRequest) {
-  //   if (!hasOwnProperty(pendingRequest, key)) {
-  //     continue;
-  //   }
-  //   for (const req of pendingRequest[key]) {
-  //     // reject already fulfilled promise does nothing
-  //     req.reject();
-  //   }
-  // }
-
   siteBasedCache = new SiteBasedCache();
   fetchPromisesCache = new FetchPromisesCache();
   pendingRequest = {};
@@ -297,41 +274,16 @@
 }
 
 @customElement('gr-rest-api-interface')
-export class GrRestApiInterface
-  extends GestureEventListeners(LegacyElementMixin(PolymerElement))
+export class GrRestApiInterface extends PolymerElement
   implements RestApiService {
-  readonly JSON_PREFIX = JSON_PREFIX;
-  /**
-   * Fired when an server error occurs.
-   *
-   * @event server-error
-   */
-
-  /**
-   * Fired when a network error occurs.
-   *
-   * @event network-error
-   */
-
-  /**
-   * Fired after an RPC completes.
-   *
-   * @event rpc-log
-   */
-
-  @property({type: Object})
   readonly _cache = siteBasedCache; // Shared across instances.
 
-  @property({type: Object})
   readonly _sharedFetchPromises = fetchPromisesCache; // Shared across instances.
 
-  @property({type: Object})
   readonly _pendingRequests = pendingRequest; // Shared across instances.
 
-  @property({type: Object})
   readonly _etags = grEtagDecorator; // Shared across instances.
 
-  @property({type: Object})
   readonly _projectLookup = projectLookup; // Shared across instances.
 
   // The value is set in created, before any other actions
@@ -340,14 +292,15 @@
   // The value is set in created, before any other actions
   private readonly _restApiHelper: GrRestApiHelper;
 
-  constructor() {
+  constructor(authService?: AuthService) {
     super();
-    this.authService = appContext.authService;
+    // TODO: Make the authService constructor parameter required when we have
+    // changed all usages of this class to not instantiate via createElement().
+    this.authService = authService ?? appContext.authService;
     this._restApiHelper = new GrRestApiHelper(
       this._cache,
       this.authService,
-      this._sharedFetchPromises,
-      this
+      this._sharedFetchPromises
     );
   }
 
@@ -422,19 +375,7 @@
     }) as Promise<DashboardInfo[] | undefined>;
   }
 
-  saveRepoConfig(repo: RepoName, config: ConfigInput): Promise<Response>;
-
-  saveRepoConfig(
-    repo: RepoName,
-    config: ConfigInput,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  saveRepoConfig(
-    repo: RepoName,
-    config: ConfigInput,
-    errFn?: ErrorCallback
-  ): Promise<Response | undefined> {
+  saveRepoConfig(repo: RepoName, config: ConfigInput): Promise<Response> {
     // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
     // supports it.
     const url = `/projects/${encodeURIComponent(repo)}/config`;
@@ -443,23 +384,11 @@
       method: HttpMethod.PUT,
       url,
       body: config,
-      errFn,
       anonymizedUrl: '/projects/*/config',
     });
   }
 
-  runRepoGC(repo: RepoName): Promise<Response>;
-
-  runRepoGC(
-    repo: RepoName,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  runRepoGC(repo: RepoName, errFn?: ErrorCallback) {
-    if (!repo) {
-      // TODO(TS): fix return value
-      return '';
-    }
+  runRepoGC(repo: RepoName): Promise<Response> {
     // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
     // supports it.
     const encodeName = encodeURIComponent(repo);
@@ -467,23 +396,11 @@
       method: HttpMethod.POST,
       url: `/projects/${encodeName}/gc`,
       body: '',
-      errFn,
       anonymizedUrl: '/projects/*/gc',
     });
   }
 
-  createRepo(config: ProjectInput & {name: RepoName}): Promise<Response>;
-
-  createRepo(
-    config: ProjectInput & {name: RepoName},
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  createRepo(config: ProjectInput, errFn?: ErrorCallback) {
-    if (!config.name) {
-      // TODO(TS): Fix return value
-      return '';
-    }
+  createRepo(config: ProjectInput & {name: RepoName}): Promise<Response> {
     // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
     // supports it.
     const encodeName = encodeURIComponent(config.name);
@@ -491,29 +408,16 @@
       method: HttpMethod.PUT,
       url: `/projects/${encodeName}`,
       body: config,
-      errFn,
       anonymizedUrl: '/projects/*',
     });
   }
 
-  createGroup(config: GroupInput & {name: string}): Promise<Response>;
-
-  createGroup(
-    config: GroupInput & {name: string},
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  createGroup(config: GroupInput, errFn?: ErrorCallback) {
-    if (!config.name) {
-      // TODO(TS): Fix return value
-      return '';
-    }
+  createGroup(config: GroupInput & {name: string}): Promise<Response> {
     const encodeName = encodeURIComponent(config.name);
     return this._restApiHelper.send({
       method: HttpMethod.PUT,
       url: `/groups/${encodeName}`,
       body: config,
-      errFn,
       anonymizedUrl: '/groups/*',
     });
   }
@@ -529,19 +433,7 @@
     }) as Promise<GroupInfo | undefined>;
   }
 
-  deleteRepoBranches(repo: RepoName, ref: GitRef): Promise<Response>;
-
-  deleteRepoBranches(
-    repo: RepoName,
-    ref: GitRef,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  deleteRepoBranches(repo: RepoName, ref: GitRef, errFn?: ErrorCallback) {
-    if (!repo || !ref) {
-      // TODO(TS): fix return value
-      return '';
-    }
+  deleteRepoBranches(repo: RepoName, ref: GitRef): Promise<Response> {
     // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
     // supports it.
     const encodeName = encodeURIComponent(repo);
@@ -550,24 +442,11 @@
       method: HttpMethod.DELETE,
       url: `/projects/${encodeName}/branches/${encodeRef}`,
       body: '',
-      errFn,
       anonymizedUrl: '/projects/*/branches/*',
     });
   }
 
-  deleteRepoTags(repo: RepoName, ref: GitRef): Promise<Response>;
-
-  deleteRepoTags(
-    repo: RepoName,
-    ref: GitRef,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  deleteRepoTags(repo: RepoName, ref: GitRef, errFn?: ErrorCallback) {
-    if (!repo || !ref) {
-      // TODO(TS): fix return type
-      return '';
-    }
+  deleteRepoTags(repo: RepoName, ref: GitRef): Promise<Response> {
     // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
     // supports it.
     const encodeName = encodeURIComponent(repo);
@@ -576,7 +455,6 @@
       method: HttpMethod.DELETE,
       url: `/projects/${encodeName}/tags/${encodeRef}`,
       body: '',
-      errFn,
       anonymizedUrl: '/projects/*/tags/*',
     });
   }
@@ -585,25 +463,7 @@
     name: RepoName,
     branch: BranchName,
     revision: BranchInput
-  ): Promise<Response>;
-
-  createRepoBranch(
-    name: RepoName,
-    branch: BranchName,
-    revision: BranchInput,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  createRepoBranch(
-    name: RepoName,
-    branch: BranchName,
-    revision: BranchInput,
-    errFn?: ErrorCallback
-  ) {
-    if (!name || !branch || !revision) {
-      // TODO(TS) fix return type
-      return '';
-    }
+  ): Promise<Response> {
     // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
     // supports it.
     const encodeName = encodeURIComponent(name);
@@ -612,7 +472,6 @@
       method: HttpMethod.PUT,
       url: `/projects/${encodeName}/branches/${encodeBranch}`,
       body: revision,
-      errFn,
       anonymizedUrl: '/projects/*/branches/*',
     });
   }
@@ -621,25 +480,7 @@
     name: RepoName,
     tag: string,
     revision: TagInput
-  ): Promise<Response>;
-
-  createRepoTag(
-    name: RepoName,
-    tag: string,
-    revision: TagInput,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  createRepoTag(
-    name: RepoName,
-    tag: string,
-    revision: TagInput,
-    errFn?: ErrorCallback
-  ) {
-    if (!name || !tag || !revision) {
-      // TODO(TS): Fix return value
-      return '';
-    }
+  ): Promise<Response> {
     // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
     // supports it.
     const encodeName = encodeURIComponent(name);
@@ -648,7 +489,6 @@
       method: HttpMethod.PUT,
       url: `/projects/${encodeName}/tags/${encodeTag}`,
       body: revision,
-      errFn,
       anonymizedUrl: '/projects/*/tags/*',
     });
   }
@@ -664,16 +504,12 @@
     );
   }
 
-  getGroupMembers(
-    groupName: GroupId | GroupName,
-    errFn?: ErrorCallback
-  ): Promise<AccountInfo[] | undefined> {
+  getGroupMembers(groupName: GroupId | GroupName): Promise<AccountInfo[]> {
     const encodeName = encodeURIComponent(groupName);
-    return this._restApiHelper.fetchJSON({
+    return (this._restApiHelper.fetchJSON({
       url: `/groups/${encodeName}/members/`,
-      errFn,
       anonymizedUrl: '/groups/*/members',
-    }) as Promise<AccountInfo[] | undefined>;
+    }) as unknown) as Promise<AccountInfo[]>;
   }
 
   getIncludedGroup(
@@ -823,27 +659,7 @@
           reportUrlAsIs: true,
         }) as Promise<DiffPreferencesInfo | undefined>;
       }
-      const anonymousResult: DiffPreferencesInfo = {
-        auto_hide_diff_table_header: true,
-        context: 10,
-        cursor_blink_rate: 0,
-        font_size: 12,
-        ignore_whitespace: IgnoreWhitespaceType.IGNORE_NONE,
-        intraline_difference: true,
-        line_length: 100,
-        line_wrapping: false,
-        show_line_endings: true,
-        show_tabs: true,
-        show_whitespace_errors: true,
-        syntax_highlighting: true,
-        tab_size: 8,
-        theme: 'DEFAULT',
-      };
-      // These defaults should match the defaults in
-      // java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
-      // NOTE: There are some settings that don't apply to PolyGerrit
-      // (Render mode being at least one of them).
-      return Promise.resolve(anonymousResult);
+      return Promise.resolve(createDefaultDiffPrefs());
     });
   }
 
@@ -855,38 +671,11 @@
           reportUrlAsIs: true,
         }) as Promise<EditPreferencesInfo | undefined>;
       }
-      const result: EditPreferencesInfo = {
-        auto_close_brackets: false,
-        cursor_blink_rate: 0,
-        hide_line_numbers: false,
-        hide_top_menu: false,
-        indent_unit: 2,
-        indent_with_tabs: false,
-        key_map_type: 'DEFAULT',
-        line_length: 100,
-        line_wrapping: false,
-        match_brackets: true,
-        show_base: false,
-        show_tabs: true,
-        show_whitespace_errors: true,
-        syntax_highlighting: true,
-        tab_size: 8,
-        theme: 'DEFAULT',
-      };
-      // These defaults should match the defaults in
-      // java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
-      return Promise.resolve(result);
+      return Promise.resolve(createDefaultEditPrefs());
     });
   }
 
-  savePreferences(prefs: PreferencesInput): Promise<Response>;
-
-  savePreferences(
-    prefs: PreferencesInput,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  savePreferences(prefs: PreferencesInput, errFn?: ErrorCallback) {
+  savePreferences(prefs: PreferencesInput): Promise<Response> {
     // Note (Issue 5142): normalize the download scheme with lower case before
     // saving.
     if (prefs.download_scheme) {
@@ -897,45 +686,28 @@
       method: HttpMethod.PUT,
       url: '/accounts/self/preferences',
       body: prefs,
-      errFn,
       reportUrlAsIs: true,
     });
   }
 
-  saveDiffPreferences(prefs: DiffPreferenceInput): Promise<Response>;
-
-  saveDiffPreferences(
-    prefs: DiffPreferenceInput,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  saveDiffPreferences(prefs: DiffPreferenceInput, errFn?: ErrorCallback) {
+  saveDiffPreferences(prefs: DiffPreferenceInput): Promise<Response> {
     // Invalidate the cache.
     this._cache.delete('/accounts/self/preferences.diff');
     return this._restApiHelper.send({
       method: HttpMethod.PUT,
       url: '/accounts/self/preferences.diff',
       body: prefs,
-      errFn,
       reportUrlAsIs: true,
     });
   }
 
-  saveEditPreferences(prefs: EditPreferencesInfo): Promise<Response>;
-
-  saveEditPreferences(
-    prefs: EditPreferencesInfo,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  saveEditPreferences(prefs: EditPreferencesInfo, errFn?: ErrorCallback) {
+  saveEditPreferences(prefs: EditPreferencesInfo): Promise<Response> {
     // Invalidate the cache.
     this._cache.delete('/accounts/self/preferences.edit');
     return this._restApiHelper.send({
       method: HttpMethod.PUT,
       url: '/accounts/self/preferences.edit',
       body: prefs,
-      errFn,
       reportUrlAsIs: true,
     });
   }
@@ -995,48 +767,28 @@
     }) as Promise<EmailInfo[] | undefined>;
   }
 
-  addAccountEmail(email: string): Promise<Response>;
-
-  addAccountEmail(
-    email: string,
-    errFn?: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  addAccountEmail(email: string, errFn?: ErrorCallback) {
+  addAccountEmail(email: string): Promise<Response> {
     return this._restApiHelper.send({
       method: HttpMethod.PUT,
       url: '/accounts/self/emails/' + encodeURIComponent(email),
-      errFn,
       anonymizedUrl: '/account/self/emails/*',
     });
   }
 
-  deleteAccountEmail(email: string): Promise<Response>;
-
-  deleteAccountEmail(
-    email: string,
-    errFn?: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  deleteAccountEmail(email: string, errFn?: ErrorCallback) {
+  deleteAccountEmail(email: string): Promise<Response> {
     return this._restApiHelper.send({
       method: HttpMethod.DELETE,
       url: '/accounts/self/emails/' + encodeURIComponent(email),
-      errFn,
       anonymizedUrl: '/accounts/self/email/*',
     });
   }
 
-  setPreferredAccountEmail(
-    email: string,
-    errFn?: ErrorCallback
-  ): Promise<void> {
+  setPreferredAccountEmail(email: string): Promise<void> {
     // TODO(TS): add correct error handling
     const encodedEmail = encodeURIComponent(email);
     const req = {
       method: HttpMethod.PUT,
       url: `/accounts/self/emails/${encodedEmail}/preferred`,
-      errFn,
       anonymizedUrl: '/accounts/self/emails/*/preferred',
     };
     return this._restApiHelper.send(req).then(() => {
@@ -1066,13 +818,12 @@
     }
   }
 
-  setAccountName(name: string, errFn?: ErrorCallback): Promise<void> {
+  setAccountName(name: string): Promise<void> {
     // TODO(TS): add correct error handling
     const req: SendJSONRequest = {
       method: HttpMethod.PUT,
       url: '/accounts/self/name',
       body: {name},
-      errFn,
       parseResponse: true,
       reportUrlAsIs: true,
     };
@@ -1083,13 +834,12 @@
       );
   }
 
-  setAccountUsername(username: string, errFn?: ErrorCallback): Promise<void> {
+  setAccountUsername(username: string): Promise<void> {
     // TODO(TS): add correct error handling
     const req: SendJSONRequest = {
       method: HttpMethod.PUT,
       url: '/accounts/self/username',
       body: {username},
-      errFn,
       parseResponse: true,
       reportUrlAsIs: true,
     };
@@ -1100,16 +850,12 @@
       );
   }
 
-  setAccountDisplayName(
-    displayName: string,
-    errFn?: ErrorCallback
-  ): Promise<void> {
+  setAccountDisplayName(displayName: string): Promise<void> {
     // TODO(TS): add correct error handling
     const req: SendJSONRequest = {
       method: HttpMethod.PUT,
       url: '/accounts/self/displayname',
       body: {display_name: displayName},
-      errFn,
       parseResponse: true,
       reportUrlAsIs: true,
     };
@@ -1120,13 +866,12 @@
     );
   }
 
-  setAccountStatus(status: string, errFn?: ErrorCallback): Promise<void> {
+  setAccountStatus(status: string): Promise<void> {
     // TODO(TS): add correct error handling
     const req: SendJSONRequest = {
       method: HttpMethod.PUT,
       url: '/accounts/self/status',
       body: {status},
-      errFn,
       parseResponse: true,
       reportUrlAsIs: true,
     };
@@ -1227,19 +972,7 @@
           return prefInfo;
         });
       }
-
-      // TODO(TS): Many properties are omitted here, but they are required.
-      // Add default values for missed properties
-      const anonymousPrefs = {
-        changes_per_page: 25,
-        default_diff_view: this._isNarrowScreen()
-          ? DiffViewMode.UNIFIED
-          : DiffViewMode.SIDE_BY_SIDE,
-        diff_view: DiffViewMode.SIDE_BY_SIDE,
-        size_bar_in_change_table: true,
-      } as PreferencesInfo;
-
-      return anonymousPrefs;
+      return createDefaultPreferences();
     });
   }
 
@@ -1251,34 +984,22 @@
   }
 
   saveWatchedProjects(
-    projects: ProjectWatchInfo[],
-    errFn?: ErrorCallback
+    projects: ProjectWatchInfo[]
   ): Promise<ProjectWatchInfo[]> {
     return (this._restApiHelper.send({
       method: HttpMethod.POST,
       url: '/accounts/self/watched.projects',
       body: projects,
-      errFn,
       parseResponse: true,
       reportUrlAsIs: true,
     }) as unknown) as Promise<ProjectWatchInfo[]>;
   }
 
-  deleteWatchedProjects(
-    projects: ProjectWatchInfo[]
-  ): Promise<Response | undefined>;
-
-  deleteWatchedProjects(
-    projects: ProjectWatchInfo[],
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  deleteWatchedProjects(projects: ProjectWatchInfo[], errFn?: ErrorCallback) {
+  deleteWatchedProjects(projects: ProjectWatchInfo[]): Promise<Response> {
     return this._restApiHelper.send({
       method: HttpMethod.POST,
       url: '/accounts/self/watched.projects:delete',
       body: projects,
-      errFn,
       reportUrlAsIs: true,
     });
   }
@@ -1457,11 +1178,7 @@
     return listChangesOptionsToHex(...options);
   }
 
-  getDiffChangeDetail(
-    changeNum: NumericChangeId,
-    errFn?: ErrorCallback,
-    cancelCondition?: CancelConditionCallback
-  ) {
+  getDiffChangeDetail(changeNum: NumericChangeId) {
     let optionsHex = '';
     if (window.DEFAULT_DETAIL_HEXES?.diffPage) {
       optionsHex = window.DEFAULT_DETAIL_HEXES.diffPage;
@@ -1472,7 +1189,7 @@
         ListChangesOption.SKIP_DIFFSTAT
       );
     }
-    return this._getChangeDetail(changeNum, optionsHex, errFn, cancelCondition);
+    return this._getChangeDetail(changeNum, optionsHex);
   }
 
   /**
@@ -1498,7 +1215,7 @@
         };
         return this._restApiHelper.fetchRawJSON(req).then(response => {
           if (response?.status === 304) {
-            return (this._restApiHelper.parsePrefixedJSON(
+            return (parsePrefixedJSON(
               // urlWithParams already cached
               this._etags.getCachedPayload(urlWithParams)!
             ) as unknown) as ChangeInfo;
@@ -1508,13 +1225,7 @@
             if (errFn) {
               errFn.call(null, response);
             } else {
-              this.dispatchEvent(
-                new CustomEvent('server-error', {
-                  detail: {request: req, response},
-                  composed: true,
-                  bubbles: true,
-                })
-              );
+              fireServerError(response, req);
             }
             return undefined;
           }
@@ -1523,20 +1234,18 @@
             return Promise.resolve(null);
           }
 
-          return this._restApiHelper
-            .readResponsePayload(response)
-            .then(payload => {
-              if (!payload) {
-                return null;
-              }
-              this._etags.collect(urlWithParams, response, payload.raw);
-              // TODO(TS): Why it is always change info?
-              this._maybeInsertInLookup(
-                (payload.parsed as unknown) as ChangeInfo
-              );
+          return readResponsePayload(response).then(payload => {
+            if (!payload) {
+              return null;
+            }
+            this._etags.collect(urlWithParams, response, payload.raw);
+            // TODO(TS): Why it is always change info?
+            this._maybeInsertInLookup(
+              (payload.parsed as unknown) as ChangeInfo
+            );
 
-              return (payload.parsed as unknown) as ChangeInfo;
-            });
+            return (payload.parsed as unknown) as ChangeInfo;
+          });
         });
       }
     );
@@ -1558,7 +1267,7 @@
     let params = undefined;
     if (isMergeParent(patchRange.basePatchNum)) {
       params = {parent: getParentIndex(patchRange.basePatchNum)};
-    } else if (!patchNumEquals(patchRange.basePatchNum, ParentPatchSetNum)) {
+    } else if (patchRange.basePatchNum !== ParentPatchSetNum) {
       params = {base: patchRange.basePatchNum};
     }
     return this._getChangeURLAndFetch({
@@ -1605,7 +1314,7 @@
     changeNum: NumericChangeId,
     patchRange: PatchRange
   ): Promise<FileNameToFileInfoMap | undefined> {
-    if (patchNumEquals(patchRange.patchNum, EditPatchSetNum)) {
+    if (patchRange.patchNum === EditPatchSetNum) {
       return this.getChangeEditFiles(changeNum, patchRange).then(
         res => res && res.files
       );
@@ -1628,37 +1337,22 @@
     >;
   }
 
-  getChangeSuggestedReviewers(
-    changeNum: NumericChangeId,
-    inputVal: string,
-    errFn?: ErrorCallback
-  ) {
+  getChangeSuggestedReviewers(changeNum: NumericChangeId, inputVal: string) {
     return this._getChangeSuggestedGroup(
       ReviewerState.REVIEWER,
       changeNum,
-      inputVal,
-      errFn
+      inputVal
     );
   }
 
-  getChangeSuggestedCCs(
-    changeNum: NumericChangeId,
-    inputVal: string,
-    errFn?: ErrorCallback
-  ) {
-    return this._getChangeSuggestedGroup(
-      ReviewerState.CC,
-      changeNum,
-      inputVal,
-      errFn
-    );
+  getChangeSuggestedCCs(changeNum: NumericChangeId, inputVal: string) {
+    return this._getChangeSuggestedGroup(ReviewerState.CC, changeNum, inputVal);
   }
 
   _getChangeSuggestedGroup(
     reviewerState: ReviewerState,
     changeNum: NumericChangeId,
-    inputVal: string,
-    errFn?: ErrorCallback
+    inputVal: string
   ): Promise<SuggestedReviewerInfo[] | undefined> {
     // More suggestions may obscure content underneath in the reply dialog,
     // see issue 10793.
@@ -1672,7 +1366,6 @@
     return this._getChangeURLAndFetch({
       changeNum,
       endpoint: '/suggest_reviewers',
-      errFn,
       params,
       reportEndpointAsIs: true,
     }) as Promise<SuggestedReviewerInfo[] | undefined>;
@@ -1893,8 +1586,7 @@
 
   getSuggestedGroups(
     inputVal: string,
-    n?: number,
-    errFn?: ErrorCallback
+    n?: number
   ): Promise<GroupNameToGroupInfoMap | undefined> {
     const params: QueryGroupsParams = {s: inputVal};
     if (n) {
@@ -1902,7 +1594,6 @@
     }
     return this._restApiHelper.fetchJSON({
       url: '/groups/',
-      errFn,
       params,
       reportUrlAsIs: true,
     }) as Promise<GroupNameToGroupInfoMap | undefined>;
@@ -1910,8 +1601,7 @@
 
   getSuggestedProjects(
     inputVal: string,
-    n?: number,
-    errFn?: ErrorCallback
+    n?: number
   ): Promise<NameToProjectInfoMap | undefined> {
     const params = {
       m: inputVal,
@@ -1923,7 +1613,6 @@
     }
     return this._restApiHelper.fetchJSON({
       url: '/projects/',
-      errFn,
       params,
       reportUrlAsIs: true,
     });
@@ -1931,8 +1620,7 @@
 
   getSuggestedAccounts(
     inputVal: string,
-    n?: number,
-    errFn?: ErrorCallback
+    n?: number
   ): Promise<AccountInfo[] | undefined> {
     if (!inputVal) {
       return Promise.resolve([]);
@@ -1943,7 +1631,6 @@
     }
     return this._restApiHelper.fetchJSON({
       url: '/accounts/',
-      errFn,
       params,
       anonymizedUrl: '/accounts/?n=*',
     }) as Promise<AccountInfo[] | undefined>;
@@ -2104,29 +1791,12 @@
     patchNum: PatchSetNum,
     path: string,
     reviewed: boolean
-  ): Promise<Response>;
-
-  saveFileReviewed(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
-    path: string,
-    reviewed: boolean,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  saveFileReviewed(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
-    path: string,
-    reviewed: boolean,
-    errFn?: ErrorCallback
-  ) {
+  ): Promise<Response> {
     return this._getChangeURLAndSend({
       changeNum,
       method: reviewed ? HttpMethod.PUT : HttpMethod.DELETE,
       patchNum,
       endpoint: `/files/${encodeURIComponent(path)}/reviewed`,
-      errFn,
       anonymizedEndpoint: '/files/*/reviewed',
     });
   }
@@ -2221,20 +1891,15 @@
     // 404s indicate the file does not exist yet in the revision, so suppress
     // them.
     const suppress404s: ErrorCallback = res => {
-      if (res?.status !== 404) {
-        this.dispatchEvent(
-          new CustomEvent('server-error', {
-            detail: {res},
-            composed: true,
-            bubbles: true,
-          })
-        );
+      if (res && res?.status !== 404) {
+        fireServerError(res);
       }
       return res;
     };
-    const promise = patchNumEquals(patchNum, EditPatchSetNum)
-      ? this._getFileInChangeEdit(changeNum, path)
-      : this._getFileInRevision(changeNum, path, patchNum, suppress404s);
+    const promise =
+      patchNum === EditPatchSetNum
+        ? this._getFileInChangeEdit(changeNum, path)
+        : this._getFileInRevision(changeNum, path, patchNum, suppress404s);
 
     return promise.then(res => {
       if (!res || !res.ok) {
@@ -2521,14 +2186,12 @@
     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);
-    } else if (!patchNumEquals(basePatchNum, ParentPatchSetNum)) {
-      // TODO (TS): fix as PatchSetNum in the condition above
+    } else if (basePatchNum !== ParentPatchSetNum) {
       params.base = basePatchNum;
     }
     const endpoint = `/files/${encodeURIComponent(path)}/diff`;
@@ -2569,11 +2232,15 @@
     path?: string
   ) {
     if (!basePatchNum && !patchNum && !path) {
-      return this._getDiffComments(changeNum, '/comments');
+      return this._getDiffComments(changeNum, '/comments', {
+        'enable-context': true,
+        'context-padding': 3,
+      });
     }
     return this._getDiffComments(
       changeNum,
       '/comments',
+      {'enable-context': true, 'context-padding': 3},
       basePatchNum,
       patchNum,
       path
@@ -2604,6 +2271,7 @@
     return this._getDiffComments(
       changeNum,
       '/robotcomments',
+      undefined,
       basePatchNum,
       patchNum,
       path
@@ -2642,6 +2310,7 @@
       return this._getDiffComments(
         changeNum,
         '/drafts',
+        undefined,
         basePatchNum,
         patchNum,
         path
@@ -2674,7 +2343,8 @@
 
   _getDiffComments(
     changeNum: NumericChangeId,
-    endpoint: '/comments' | '/drafts'
+    endpoint: '/comments' | '/drafts',
+    params?: FetchParams
   ): Promise<PathToCommentsInfoMap | undefined>;
 
   _getDiffComments(
@@ -2685,6 +2355,7 @@
   _getDiffComments(
     changeNum: NumericChangeId,
     endpoint: '/comments' | '/drafts',
+    params?: FetchParams,
     basePatchNum?: PatchSetNum,
     patchNum?: PatchSetNum,
     path?: string
@@ -2693,6 +2364,7 @@
   _getDiffComments(
     changeNum: NumericChangeId,
     endpoint: '/robotcomments',
+    params?: FetchParams,
     basePatchNum?: PatchSetNum,
     patchNum?: PatchSetNum,
     path?: string
@@ -2701,6 +2373,7 @@
   _getDiffComments(
     changeNum: NumericChangeId,
     endpoint: string,
+    params?: FetchParams,
     basePatchNum?: PatchSetNum,
     patchNum?: PatchSetNum,
     path?: string
@@ -2725,6 +2398,7 @@
           endpoint,
           revision: patchNum,
           reportEndpointAsIs: true,
+          params,
         },
         noAcceptHeader
       ) as Promise<
@@ -2793,6 +2467,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,
@@ -3045,10 +2756,7 @@
     });
   }
 
-  setChangeTopic(
-    changeNum: NumericChangeId,
-    topic: string | null
-  ): Promise<string> {
+  setChangeTopic(changeNum: NumericChangeId, topic?: string): Promise<string> {
     return (this._getChangeURLAndSend({
       changeNum,
       method: HttpMethod.PUT,
@@ -3219,10 +2927,9 @@
     }) as Promise<CapabilityInfoMap | undefined>;
   }
 
-  getTopMenus(errFn?: ErrorCallback): Promise<TopMenuEntryInfo[] | undefined> {
+  getTopMenus(): Promise<TopMenuEntryInfo[] | undefined> {
     return this._fetchSharedCacheURL({
       url: '/config/server/top-menus',
-      errFn,
       reportUrlAsIs: true,
     }) as Promise<TopMenuEntryInfo[] | undefined>;
   }
@@ -3276,21 +2983,6 @@
     });
   }
 
-  startReview(
-    changeNum: NumericChangeId,
-    body?: RequestPayload,
-    errFn?: ErrorCallback
-  ) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.POST,
-      endpoint: '/ready',
-      body,
-      errFn,
-      reportUrlAsIs: true,
-    });
-  }
-
   deleteComment(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
@@ -3357,16 +3049,7 @@
       return Promise.resolve(project);
     }
 
-    const onError = (response?: Response | null) => {
-      // Fire a page error so that the visual 404 is displayed.
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
-    };
+    const onError = (response?: Response | null) => firePageError(response);
 
     return this.getChange(changeNum, onError).then(change => {
       if (!change || !change.project) {
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 505fd58..7cbcaef 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
@@ -16,13 +16,18 @@
  */
 
 import '../../../test/common-test-setup-karma.js';
-import './gr-rest-api-interface.js';
-import {mockPromise} from '../../../test/test-utils.js';
+import {addListenerForTest, mockPromise} from '../../../test/test-utils.js';
 import {GrReviewerUpdatesParser} from './gr-reviewer-updates-parser.js';
 import {ListChangesOption} from '../../../utils/change-util.js';
 import {appContext} from '../../../services/app-context.js';
-
-const basicFixture = fixtureFromElement('gr-rest-api-interface');
+import {createChange} from '../../../test/test-data-generators.js';
+import {CURRENT} from '../../../utils/patch-set-util.js';
+import {
+  parsePrefixedJSON,
+  readResponsePayload,
+} from './gr-rest-apis/gr-rest-api-helper.js';
+import {JSON_PREFIX} from './gr-rest-apis/gr-rest-api-helper.js';
+import {GrRestApiInterface} from './gr-rest-api-interface.js';
 
 suite('gr-rest-api-interface tests', () => {
   let element;
@@ -46,7 +51,7 @@
     // fake auth
     sinon.stub(appContext.authService, 'authCheck')
         .returns(Promise.resolve(true));
-    element = basicFixture.instantiate();
+    element = new GrRestApiInterface();
     element._projectLookup = {};
   });
 
@@ -70,7 +75,8 @@
             },
           ],
         }));
-    return element._getDiffComments('42', '', 'PARENT', 1, 'sieve.go').then(
+    return element._getDiffComments('42', '', undefined, 'PARENT', 1,
+        'sieve.go').then(
         obj => {
           assert.equal(obj.baseComments.length, 1);
           assert.deepEqual(obj.baseComments[0], {
@@ -234,7 +240,7 @@
         });
       }
     });
-    return element._getDiffComments('42', '', 1, 2, 'sieve.go').then(
+    return element._getDiffComments('42', '', undefined, 1, 2, 'sieve.go').then(
         obj => {
           assert.equal(obj.baseComments.length, 1);
           assert.deepEqual(obj.baseComments[0], {
@@ -260,7 +266,7 @@
     const getResponseObjectStub = sinon.stub(element, 'getResponseObject');
     window.fetch.returns(Promise.resolve({ok: false}));
     const serverErrorEventPromise = new Promise(resolve => {
-      element.addEventListener('server-error', resolve);
+      addListenerForTest(document, 'server-error', resolve);
     });
 
     return Promise.all([element._restApiHelper.fetchJSON({}).then(response => {
@@ -351,19 +357,6 @@
         });
       });
 
-  test('getPreferences returns correctly on small screens not logged in',
-      () => {
-        const testJSON = {diff_view: 'SIDE_BY_SIDE'};
-        const loggedIn = false;
-        const smallScreen = true;
-
-        preferenceSetup(testJSON, loggedIn, smallScreen);
-        return element.getPreferences().then(obj => {
-          assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
-          assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
-        });
-      });
-
   test('getPreferences returns correctly on larger screens logged in',
       () => {
         const testJSON = {diff_view: 'UNIFIED_DIFF'};
@@ -403,12 +396,10 @@
     sinon.stub(element, 'getLoggedIn').callsFake(() => Promise.resolve(false));
 
     return element.getDiffPreferences().then(obj => {
-      assert.equal(obj.auto_hide_diff_table_header, true);
       assert.equal(obj.context, 10);
       assert.equal(obj.cursor_blink_rate, 0);
       assert.equal(obj.font_size, 12);
       assert.equal(obj.ignore_whitespace, 'IGNORE_NONE');
-      assert.equal(obj.intraline_difference, true);
       assert.equal(obj.line_length, 100);
       assert.equal(obj.line_wrapping, false);
       assert.equal(obj.show_line_endings, true);
@@ -416,7 +407,6 @@
       assert.equal(obj.show_whitespace_errors, true);
       assert.equal(obj.syntax_highlighting, true);
       assert.equal(obj.tab_size, 8);
-      assert.equal(obj.theme, 'DEFAULT');
     });
   });
 
@@ -647,19 +637,6 @@
         {message: 'revising...'});
   });
 
-  test('startReview', () => {
-    const sendStub = sinon.stub(element, '_getChangeURLAndSend')
-        .returns(Promise.resolve({}));
-    element.startReview('42', {message: 'Please review.'});
-    assert.isTrue(sendStub.calledOnce);
-    assert.equal(sendStub.lastCall.args[0].changeNum, '42');
-    assert.equal(sendStub.lastCall.args[0].method, 'POST');
-    assert.isNotOk(sendStub.lastCall.args[0].patchNum);
-    assert.equal(sendStub.lastCall.args[0].endpoint, '/ready');
-    assert.deepEqual(sendStub.lastCall.args[0].body,
-        {message: 'Please review.'});
-  });
-
   test('deleteComment', () => {
     const sendStub = sinon.stub(element, '_getChangeURLAndSend')
         .returns(Promise.resolve('some response'));
@@ -943,22 +920,18 @@
       });
     });
 
-    test('_getChangeDetail populates _projectLookup', () => {
+    test('_getChangeDetail populates _projectLookup', async () => {
       sinon.stub(element, 'getChangeActionURL')
           .returns(Promise.resolve(''));
       sinon.stub(element._restApiHelper, 'fetchRawJSON')
-          .returns(Promise.resolve({ok: true}));
-
-      const mockResponse = {_number: 1, project: 'test'};
-      sinon.stub(element._restApiHelper, 'readResponsePayload')
           .returns(Promise.resolve({
-            parsed: mockResponse,
-            raw: JSON.stringify(mockResponse),
+            ok: true,
+            status: 200,
+            text: () => Promise.resolve(`)]}'{"_number":1,"project":"test"}`),
           }));
-      return element._getChangeDetail(1, '516714').then(() => {
-        assert.equal(Object.keys(element._projectLookup).length, 1);
-        assert.equal(element._projectLookup[1], 'test');
-      });
+      await element._getChangeDetail(1, '516714');
+      assert.equal(Object.keys(element._projectLookup).length, 1);
+      assert.equal(element._projectLookup[1], 'test');
     });
 
     suite('_getChangeDetail ETag cache', () => {
@@ -969,8 +942,7 @@
       setup(() => {
         requestUrl = '/foo/bar';
         const mockResponse = {foo: 'bar', baz: 42};
-        mockResponseSerial = element.JSON_PREFIX +
-            JSON.stringify(mockResponse);
+        mockResponseSerial = JSON_PREFIX + JSON.stringify(mockResponse);
         sinon.stub(element._restApiHelper, 'urlWithParams')
             .returns(requestUrl);
         sinon.stub(element, 'getChangeActionURL')
@@ -1117,21 +1089,19 @@
   });
 
   suite('reading responses', () => {
-    test('_readResponsePayload', () => {
+    test('_readResponsePayload', async () => {
       const mockObject = {foo: 'bar', baz: 'foo'};
-      const serial = element.JSON_PREFIX + JSON.stringify(mockObject);
+      const serial = JSON_PREFIX + JSON.stringify(mockObject);
       const mockResponse = {text: () => Promise.resolve(serial)};
-      return element._restApiHelper.readResponsePayload(mockResponse)
-          .then(payload => {
-            assert.deepEqual(payload.parsed, mockObject);
-            assert.equal(payload.raw, serial);
-          });
+      const payload = await readResponsePayload(mockResponse);
+      assert.deepEqual(payload.parsed, mockObject);
+      assert.equal(payload.raw, serial);
     });
 
     test('_parsePrefixedJSON', () => {
       const obj = {x: 3, y: {z: 4}, w: 23};
-      const serial = element.JSON_PREFIX + JSON.stringify(obj);
-      const result = element._restApiHelper.parsePrefixedJSON(serial);
+      const serial = JSON_PREFIX + JSON.stringify(obj);
+      const result = parsePrefixedJSON(serial);
       assert.deepEqual(result, obj);
     });
   });
@@ -1279,7 +1249,7 @@
   test('getFileContent suppresses 404s', () => {
     const res = {status: 404};
     const spy = sinon.spy();
-    element.addEventListener('server-error', spy);
+    addListenerForTest(document, 'server-error', spy);
     sinon.stub(appContext.authService, 'fetch').returns(Promise.resolve(res));
     sinon.stub(element, '_changeBaseURL').returns(Promise.resolve(''));
     return element.getFileContent('1', 'tst/path', '1')
@@ -1292,7 +1262,7 @@
         })
         .then(() => {
           assert.isTrue(spy.called);
-          assert.notEqual(spy.lastCall.args[0].detail.res.status, 404);
+          assert.notEqual(spy.lastCall.args[0].detail.response.status, 404);
         });
   });
 
@@ -1332,7 +1302,7 @@
   test('_logCall only reports requests with anonymized URLss', () => {
     sinon.stub(Date, 'now').returns(200);
     const handler = sinon.stub();
-    element.addEventListener('rpc-log', handler);
+    addListenerForTest(document, 'gr-rpc-log', handler);
 
     element._restApiHelper._logCall({url: 'url'}, 100, 200);
     assert.isFalse(handler.called);
@@ -1343,6 +1313,29 @@
     assert.isTrue(handler.calledOnce);
   });
 
+  test('ported comment errors do not trigger error dialog', () => {
+    const change = createChange();
+    const handler = sinon.stub();
+    addListenerForTest(document, 'server-error', handler);
+    sinon.stub(element._restApiHelper, 'fetchJSON').returns(Promise.resolve({
+      ok: false}));
+
+    element.getPortedComments(change._number, CURRENT);
+
+    assert.isFalse(handler.called);
+  });
+
+  test('ported drafts are not requested user is not logged in', () => {
+    const change = createChange();
+    sinon.stub(element, 'getLoggedIn').returns(Promise.resolve(false));
+    const getChangeURLAndFetchStub = sinon.stub(element,
+        '_getChangeURLAndFetch');
+
+    element.getPortedDrafts(change._number, CURRENT);
+
+    assert.isFalse(getChangeURLAndFetchStub.called);
+  });
+
   test('saveChangeStarred', async () => {
     sinon.stub(element, 'getFromProjectLookup')
         .returns(Promise.resolve('test'));
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
index 6d93604..89abd57 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
@@ -15,16 +15,11 @@
  * limitations under the License.
  */
 import {getBaseUrl} from '../../../../utils/url-util';
-import {
-  CancelConditionCallback,
-  ErrorCallback,
-  RestApiService,
-} from '../../../../services/services/gr-rest-api/gr-rest-api';
+import {CancelConditionCallback} from '../../../../services/gr-rest-api/gr-rest-api';
 import {
   AuthRequestInit,
   AuthService,
 } from '../../../../services/gr-auth/gr-auth';
-import {hasOwnProperty} from '../../../../utils/common-util';
 import {
   AccountDetailInfo,
   EmailInfo,
@@ -33,8 +28,11 @@
 } from '../../../../types/common';
 import {HttpMethod} from '../../../../constants/constants';
 import {RpcLogEventDetail} from '../../../../types/events';
+import {fireNetworkError, fireServerError} from '../../../../utils/event-util';
+import {FetchRequest} from '../../../../types/types';
+import {ErrorCallback} from '../../../../api/rest';
 
-const JSON_PREFIX = ")]}'";
+export const JSON_PREFIX = ")]}'";
 
 export interface ResponsePayload {
   // TODO(TS): readResponsePayload can assign null to the parsed property if
@@ -46,6 +44,29 @@
   raw: string;
 }
 
+export function readResponsePayload(
+  response: Response
+): Promise<ResponsePayload> {
+  return response.text().then(text => {
+    let result;
+    try {
+      result = parsePrefixedJSON(text);
+    } catch (_) {
+      result = null;
+    }
+    // TODO(TS): readResponsePayload can assign null to the parsed property if
+    // it can't parse input data. However polygerrit assumes in many places
+    // that the parsed property can't be null. We should update
+    // readResponsePayload method and reject a promise instead of assigning
+    // null to the parsed property
+    return {parsed: result!, raw: text};
+  });
+}
+
+export function parsePrefixedJSON(jsonWithPrefix: string): ParsedJSON {
+  return JSON.parse(jsonWithPrefix.substring(JSON_PREFIX.length)) as ParsedJSON;
+}
+
 /**
  * Wrapper around Map for caching server responses. Site-based so that
  * changes to CANONICAL_PATH will result in a different cache going into
@@ -54,7 +75,7 @@
 export class SiteBasedCache {
   // TODO(TS): Type looks unusual. Fix it.
   // Container of per-canonical-path caches.
-  private readonly _data = new Map<
+  private readonly data = new Map<
     string | undefined,
     unknown | Map<string, ParsedJSON | null>
   >();
@@ -72,10 +93,13 @@
 
   // Returns the cache for the current canonical path.
   _cache(): Map<string, unknown> {
-    if (!this._data.has(window.CANONICAL_PATH)) {
-      this._data.set(window.CANONICAL_PATH, new Map());
+    if (!this.data.has(window.CANONICAL_PATH)) {
+      this.data.set(
+        window.CANONICAL_PATH,
+        new Map<string, ParsedJSON | null>()
+      );
     }
-    return this._data.get(window.CANONICAL_PATH) as Map<
+    return this.data.get(window.CANONICAL_PATH) as Map<
       string,
       ParsedJSON | null
     >;
@@ -110,13 +134,13 @@
   }
 
   invalidatePrefix(prefix: string) {
-    const newMap = new Map();
+    const newMap = new Map<string, unknown>();
     for (const [key, value] of this._cache().entries()) {
       if (!key.startsWith(prefix)) {
         newMap.set(key, value);
       }
     }
-    this._data.set(window.CANONICAL_PATH, newMap);
+    this.data.set(window.CANONICAL_PATH, newMap);
   }
 }
 
@@ -125,25 +149,25 @@
 };
 
 export class FetchPromisesCache {
-  private _data: FetchPromisesCacheData;
+  private data: FetchPromisesCacheData;
 
   constructor() {
-    this._data = {};
+    this.data = {};
   }
 
   public testOnlyGetData() {
-    return this._data;
+    return this.data;
   }
 
   /**
    * @return true only if a value for a key sets and it is not undefined
    */
   has(key: string): boolean {
-    return !!this._data[key];
+    return !!this.data[key];
   }
 
   get(key: string) {
-    return this._data[key];
+    return this.data[key];
   }
 
   /**
@@ -151,17 +175,17 @@
    *     mark key as deleted.
    */
   set(key: string, value: Promise<ParsedJSON | undefined> | undefined) {
-    this._data[key] = value;
+    this.data[key] = value;
   }
 
   invalidatePrefix(prefix: string) {
     const newData: FetchPromisesCacheData = {};
-    Object.entries(this._data).forEach(([key, value]) => {
+    Object.entries(this.data).forEach(([key, value]) => {
       if (!key.startsWith(prefix)) {
         newData[key] = value;
       }
     });
-    this._data = newData;
+    this.data = newData;
   }
 }
 export type FetchParams = {
@@ -189,12 +213,6 @@
 
 export type SendRequest = SendRawRequest | SendJSONRequest;
 
-export interface FetchRequest {
-  url: string;
-  fetchOptions?: AuthRequestInit;
-  anonymizedUrl?: string;
-}
-
 export interface FetchJSONRequest extends FetchRequest {
   reportUrlAsIs?: boolean;
   params?: FetchParams;
@@ -218,8 +236,7 @@
   constructor(
     private readonly _cache: SiteBasedCache,
     private readonly _auth: AuthService,
-    private readonly _fetchPromisesCache: FetchPromisesCache,
-    private readonly _restApiInterface: RestApiService
+    private readonly _fetchPromisesCache: FetchPromisesCache
   ) {}
 
   /**
@@ -277,8 +294,8 @@
         elapsed,
         anonymizedUrl: req.anonymizedUrl,
       };
-      this.dispatchEvent(
-        new CustomEvent('rpc-log', {
+      document.dispatchEvent(
+        new CustomEvent('gr-rpc-log', {
           detail,
           composed: true,
           bubbles: true,
@@ -317,13 +334,7 @@
         if (req.errFn) {
           req.errFn.call(undefined, null, err);
         } else {
-          this.dispatchEvent(
-            new CustomEvent('network-error', {
-              detail: {error: err},
-              composed: true,
-              bubbles: true,
-            })
-          );
+          fireNetworkError(err);
         }
         throw err;
       });
@@ -352,13 +363,7 @@
           req.errFn.call(null, response);
           return;
         }
-        this.dispatchEvent(
-          new CustomEvent('server-error', {
-            detail: {request: req, response},
-            composed: true,
-            bubbles: true,
-          })
-        );
+        fireServerError(response, req);
         return;
       }
       return this.getResponseObject(response);
@@ -371,11 +376,7 @@
     }
 
     const params: Array<string | number | boolean> = [];
-    for (const p in fetchParams) {
-      if (!hasOwnProperty(fetchParams, p)) {
-        continue;
-      }
-      const paramValue = fetchParams[p];
+    for (const [p, paramValue] of Object.entries(fetchParams)) {
       // TODO(TS): Replace == null with === and check for null and undefined
       // eslint-disable-next-line eqeqeq
       if (paramValue == null) {
@@ -405,30 +406,7 @@
   }
 
   getResponseObject(response: Response): Promise<ParsedJSON> {
-    return this.readResponsePayload(response).then(payload => payload.parsed);
-  }
-
-  readResponsePayload(response: Response): Promise<ResponsePayload> {
-    return response.text().then(text => {
-      let result;
-      try {
-        result = this.parsePrefixedJSON(text);
-      } catch (_) {
-        result = null;
-      }
-      // TODO(TS): readResponsePayload can assign null to the parsed property if
-      // it can't parse input data. However polygerrit assumes in many places
-      // that the parsed property can't be null. We should update
-      // readResponsePayload method and reject a promise instead of assigning
-      // null to the parsed property
-      return {parsed: result!, raw: text};
-    });
-  }
-
-  parsePrefixedJSON(jsonWithPrefix: string): ParsedJSON {
-    return JSON.parse(
-      jsonWithPrefix.substring(JSON_PREFIX.length)
-    ) as ParsedJSON;
+    return readResponsePayload(response).then(payload => payload.parsed);
   }
 
   addAcceptJsonHeader(req: FetchJSONRequest) {
@@ -440,10 +418,6 @@
     return req;
   }
 
-  dispatchEvent(type: Event, detail?: unknown): boolean {
-    return this._restApiInterface.dispatchEvent(type, detail);
-  }
-
   fetchCacheURL(req: FetchJSONRequest): Promise<ParsedJSON | undefined> {
     if (this._fetchPromisesCache.has(req.url)) {
       return this._fetchPromisesCache.get(req.url)!;
@@ -483,7 +457,7 @@
    * Send an XHR.
    *
    * @return Promise resolves to Response/ParsedJSON only if the request is successful
-   *     (i.e. no exception and response.ok is trsue). If response fails then
+   *     (i.e. no exception and response.ok is true). If response fails then
    *     promise resolves either to void if errFn is set or rejects if errFn
    *     is not set   */
   send(req: SendRequest): Promise<Response | ParsedJSON | undefined> {
@@ -501,11 +475,8 @@
       if (!options.headers) {
         options.headers = new Headers();
       }
-      for (const header in req.headers) {
-        if (!hasOwnProperty(req.headers, header)) {
-          continue;
-        }
-        options.headers.set(header, req.headers[header]);
+      for (const [name, value] of Object.entries(req.headers)) {
+        options.headers.set(name, value);
       }
     }
     const url = req.url.startsWith('http') ? req.url : getBaseUrl() + req.url;
@@ -515,35 +486,23 @@
       anonymizedUrl: req.reportUrlAsIs ? url : req.anonymizedUrl,
     };
     const xhr = this.fetch(fetchReq)
-      .then(response => {
-        if (!response.ok) {
-          if (req.errFn) {
-            req.errFn.call(undefined, response);
-            return;
-          }
-          this.dispatchEvent(
-            new CustomEvent('server-error', {
-              detail: {request: fetchReq, response},
-              composed: true,
-              bubbles: true,
-            })
-          );
-        }
-        return response;
-      })
       .catch(err => {
-        this.dispatchEvent(
-          new CustomEvent('network-error', {
-            detail: {error: err},
-            composed: true,
-            bubbles: true,
-          })
-        );
+        fireNetworkError(err);
         if (req.errFn) {
           return req.errFn.call(undefined, null, err);
         } else {
           throw err;
         }
+      })
+      .then(response => {
+        if (response && !response.ok) {
+          if (req.errFn) {
+            req.errFn.call(undefined, response);
+            return;
+          }
+          fireServerError(response, fetchReq);
+        }
+        return response;
       });
 
     if (req.parseResponse) {
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 48a23c6..95e06c0 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,14 +22,14 @@
   ChangeInfo,
   ChangeMessageInfo,
   ChangeViewChangeInfo,
-  CommitInfo,
-  PatchSetNum,
   ReviewerUpdateInfo,
-  RevisionInfo,
   Timestamp,
 } from '../../../types/common';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {accountKey} from '../../../utils/account-util';
+import {
+  FormattedReviewerUpdateInfo,
+  ParsedChangeInfo,
+} from '../../../types/types';
 
 const MESSAGE_REVIEWERS_THRESHOLD_MILLIS = 500;
 const REVIEWER_UPDATE_THRESHOLD_MILLIS = 6000;
@@ -61,14 +61,6 @@
   updates: UpdateItem[]; // Always has at least 1 items
 }
 
-export interface FormattedReviewerUpdateInfo {
-  author: AccountInfo;
-  date: Timestamp;
-  type: 'REVIEWER_UPDATE';
-  tag: MessageTag.TAG_REVIEWER_UPDATE;
-  updates: {message: string; reviewers: AccountInfo[]}[];
-}
-
 function isParserBatchWithNonEmptyUpdates(
   x: ParserBatch
 ): x is ParserBatchWithNonEmptyUpdates {
@@ -81,19 +73,6 @@
   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' | 'revisions'> {
-  revisions: {[revisionId: string]: RevisionInfo | EditRevisionInfo};
-  reviewer_updates?: ReviewerUpdateInfo[] | FormattedReviewerUpdateInfo[];
-}
-
 type ReviewersGroupByMessage = {[message: string]: AccountInfo[]};
 
 export class GrReviewerUpdatesParser {
@@ -102,9 +81,9 @@
   // type. This class should be refactored to avoid reassignment.
   private readonly result: ChangeInfoParserInput;
 
-  private _batch: ParserBatch | null = null;
+  private batch: ParserBatch | null = null;
 
-  private _updateItems: {[accountId: string]: UpdateItem} | null = null;
+  private updateItems: {[accountId: string]: UpdateItem} | null = null;
 
   private readonly _lastState: {[accountId: string]: ReviewerState} = {};
 
@@ -126,7 +105,7 @@
    * Is a part of _groupUpdates(). Creates a new batch of updates.
    */
   private _startBatch(update: ReviewerUpdateInfo): ParserBatch {
-    this._updateItems = {};
+    this.updateItems = {};
     return {
       author: update.updated_by,
       date: update.updated,
@@ -142,12 +121,10 @@
    */
   private _completeBatch(batch: ParserBatch) {
     const items = [];
-    for (const accountId in this._updateItems) {
-      if (!hasOwnProperty(this._updateItems, accountId)) continue;
-      const updateItem = this._updateItems[accountId];
-      if (this._lastState[accountId] !== updateItem.state) {
-        this._lastState[accountId] = updateItem.state;
-        items.push(updateItem);
+    for (const [accountId, item] of Object.entries(this.updateItems ?? {})) {
+      if (this._lastState[accountId] !== item.state) {
+        this._lastState[accountId] = item.state;
+        items.push(item);
       }
     }
     if (items.length) {
@@ -165,27 +142,27 @@
   _groupUpdates(): ParserBatchWithNonEmptyUpdates[] {
     const updates = this.result.reviewer_updates;
     const newUpdates = updates.reduce((newUpdates, update) => {
-      if (!this._batch) {
-        this._batch = this._startBatch(update);
+      if (!this.batch) {
+        this.batch = this._startBatch(update);
       }
       const updateDate = parseDate(update.updated).getTime();
-      const batchUpdateDate = parseDate(this._batch.date).getTime();
+      const batchUpdateDate = parseDate(this.batch.date).getTime();
       const reviewerId = accountKey(update.reviewer);
       if (
         updateDate - batchUpdateDate > REVIEWER_UPDATE_THRESHOLD_MILLIS ||
-        update.updated_by._account_id !== this._batch.author._account_id
+        update.updated_by._account_id !== this.batch.author._account_id
       ) {
         // Next sequential update should form new group.
-        this._completeBatch(this._batch);
-        if (isParserBatchWithNonEmptyUpdates(this._batch)) {
-          newUpdates.push(this._batch);
+        this._completeBatch(this.batch);
+        if (isParserBatchWithNonEmptyUpdates(this.batch)) {
+          newUpdates.push(this.batch);
         }
-        this._batch = this._startBatch(update);
+        this.batch = this._startBatch(update);
       }
-      // _startBatch assigns _updateItems. When _groupUpdates is calling,
-      // _batch and _updateItems are not set => _startBatch is called. The
-      // _startBatch method assigns _updateItems
-      const updateItems = this._updateItems!;
+      // _startBatch assigns updateItems. When _groupUpdates is calling,
+      // batch and updateItems are not set => _startBatch is called. The
+      // _startBatch method assigns updateItems
+      const updateItems = this.updateItems!;
       updateItems[reviewerId] = {
         reviewer: update.reviewer,
         state: update.state,
@@ -197,8 +174,8 @@
     }, [] as ParserBatchWithNonEmptyUpdates[]);
     // reviewer_updates always has at least 1 item
     // (otherwise parse is not created) => updates.reduce calls callback
-    // at least once and callback assigns this._batch
-    const batch = this._batch!;
+    // at least once and callback assigns this.batch
+    const batch = this.batch!;
     this._completeBatch(batch);
     if (isParserBatchWithNonEmptyUpdates(batch)) {
       newUpdates.push(batch);
@@ -253,15 +230,10 @@
     const reviewerUpdates = (this.result
       .reviewer_updates as unknown) as ParserBatchWithNonEmptyUpdates[];
     for (const update of reviewerUpdates) {
-      const grouppedReviewers = this._groupUpdatesByMessage(update.updates);
+      const groupedReviewers = this._groupUpdatesByMessage(update.updates);
       const newUpdates: {message: string; reviewers: AccountInfo[]}[] = [];
-      for (const message in grouppedReviewers) {
-        if (hasOwnProperty(grouppedReviewers, message)) {
-          newUpdates.push({
-            message,
-            reviewers: grouppedReviewers[message],
-          });
-        }
+      for (const [message, reviewers] of Object.entries(groupedReviewers)) {
+        newUpdates.push({message, reviewers});
       }
       ((update as unknown) as FormattedReviewerUpdateInfo).updates = newUpdates;
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts
index 1369a17..a86d8f2 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts
@@ -14,10 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {customElement, property} from '@polymer/decorators';
 import {CommentRange, PatchSetNum} from '../../../types/common';
 
 export interface StorageLocation {
@@ -43,28 +39,12 @@
 CLEANUP_PREFIXES_MAX_AGE_MAP.set('draft', DURATION_DAY);
 CLEANUP_PREFIXES_MAX_AGE_MAP.set('editablecontent', DURATION_DAY);
 
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-storage': GrStorage;
-  }
-}
+export class GrStorage {
+  private lastCleanup = 0;
 
-export interface GrStorage {
-  $: {};
-}
+  private readonly storage = window.localStorage;
 
-@customElement('gr-storage')
-export class GrStorage extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
-  @property({type: Number})
-  _lastCleanup = 0;
-
-  @property({type: Object})
-  _storage = window.localStorage;
-
-  @property({type: Boolean})
-  _exceededQuota = false;
+  private exceededQuota = false;
 
   getDraftComment(location: StorageLocation): StorageObject | null {
     this._cleanupItems();
@@ -78,7 +58,7 @@
 
   eraseDraftComment(location: StorageLocation) {
     const key = this._getDraftKey(location);
-    this._storage.removeItem(key);
+    this.storage.removeItem(key);
   }
 
   getEditableContentItem(key: string): StorageObject | null {
@@ -106,7 +86,7 @@
   }
 
   eraseEditableContentItem(key: string) {
-    this._storage.removeItem(this._getEditableContentKey(key));
+    this.storage.removeItem(this._getEditableContentKey(key));
   }
 
   _getDraftKey(location: StorageLocation): string {
@@ -134,20 +114,20 @@
   _cleanupItems() {
     // Throttle cleanup to the throttle interval.
     if (
-      this._lastCleanup &&
-      Date.now() - this._lastCleanup < CLEANUP_THROTTLE_INTERVAL
+      this.lastCleanup &&
+      Date.now() - this.lastCleanup < CLEANUP_THROTTLE_INTERVAL
     ) {
       return;
     }
-    this._lastCleanup = Date.now();
+    this.lastCleanup = Date.now();
 
-    Object.keys(this._storage).forEach(key => {
+    Object.keys(this.storage).forEach(key => {
       const entries = CLEANUP_PREFIXES_MAX_AGE_MAP.entries();
       for (const [prefix, expiration] of entries) {
         if (key.startsWith(prefix)) {
           const item = this._getObject(key);
           if (!item || Date.now() - item.updated > expiration) {
-            this._storage.removeItem(key);
+            this.storage.removeItem(key);
           }
         }
       }
@@ -155,7 +135,7 @@
   }
 
   _getObject(key: string): StorageObject | null {
-    const serial = this._storage.getItem(key);
+    const serial = this.storage.getItem(key);
     if (!serial) {
       return null;
     }
@@ -163,16 +143,16 @@
   }
 
   _setObject(key: string, obj: StorageObject) {
-    if (this._exceededQuota) {
+    if (this.exceededQuota) {
       return;
     }
     try {
-      this._storage.setItem(key, JSON.stringify(obj));
+      this.storage.setItem(key, JSON.stringify(obj));
     } catch (exc) {
       // Catch for QuotaExceededError and disable writes on local storage the
       // first time that it occurs.
       if (exc.code === 22) {
-        this._exceededQuota = true;
+        this.exceededQuota = true;
         console.warn('Local storage quota exceeded: disabling');
         return;
       } else {
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.js b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.js
index 99f953f..64d3750 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.js
@@ -16,12 +16,10 @@
  */
 
 import '../../../test/common-test-setup-karma.js';
-import './gr-storage.js';
-
-const basicFixture = fixtureFromElement('gr-storage');
+import {GrStorage} from './gr-storage.js';
 
 suite('gr-storage tests', () => {
-  let element;
+  let grStorage;
 
   function mockStorage(opt_quotaExceeded) {
     return {
@@ -36,9 +34,8 @@
   }
 
   setup(() => {
-    element = basicFixture.instantiate();
-
-    element._storage = mockStorage();
+    grStorage = new GrStorage();
+    grStorage.storage = mockStorage();
   });
 
   test('storing, retrieving and erasing drafts', () => {
@@ -54,23 +51,23 @@
     };
 
     // The key is in the expected format.
-    const key = element._getDraftKey(location);
+    const key = grStorage._getDraftKey(location);
     assert.equal(key, ['draft', changeNum, patchNum, path, line].join(':'));
 
     // There should be no draft initially.
-    const draft = element.getDraftComment(location);
+    const draft = grStorage.getDraftComment(location);
     assert.isNotOk(draft);
 
     // Setting the draft stores it under the expected key.
-    element.setDraftComment(location, 'my comment');
-    assert.isOk(element._storage.getItem(key));
-    assert.equal(JSON.parse(element._storage.getItem(key)).message,
+    grStorage.setDraftComment(location, 'my comment');
+    assert.isOk(grStorage.storage.getItem(key));
+    assert.equal(JSON.parse(grStorage.storage.getItem(key)).message,
         'my comment');
-    assert.isOk(JSON.parse(element._storage.getItem(key)).updated);
+    assert.isOk(JSON.parse(grStorage.storage.getItem(key)).updated);
 
     // Erasing the draft removes the key.
-    element.eraseDraftComment(location);
-    assert.isNotOk(element._storage.getItem(key));
+    grStorage.eraseDraftComment(location);
+    assert.isNotOk(grStorage.storage.getItem(key));
   });
 
   test('automatically removes old drafts', () => {
@@ -85,25 +82,25 @@
       line,
     };
 
-    const key = element._getDraftKey(location);
+    const key = grStorage._getDraftKey(location);
 
     // Make sure that the call to cleanup doesn't get throttled.
-    element._lastCleanup = 0;
+    grStorage.lastCleanup = 0;
 
-    const cleanupSpy = sinon.spy(element, '_cleanupItems');
+    const cleanupSpy = sinon.spy(grStorage, '_cleanupItems');
 
     // Create a message with a timestamp that is a second behind the max age.
-    element._storage.setItem(key, JSON.stringify({
+    grStorage.storage.setItem(key, JSON.stringify({
       message: 'old message',
       updated: Date.now() - 24 * 60 * 60 * 1000 - 1000,
     }));
 
     // Getting the draft should cause it to be removed.
-    const draft = element.getDraftComment(location);
+    const draft = grStorage.getDraftComment(location);
 
     assert.isTrue(cleanupSpy.called);
     assert.isNotOk(draft);
-    assert.isNotOk(element._storage.getItem(key));
+    assert.isNotOk(grStorage.storage.getItem(key));
   });
 
   test('_getDraftKey', () => {
@@ -118,7 +115,7 @@
       line,
     };
     let expectedResult = 'draft:1234:5:my_source_file.js:123';
-    assert.equal(element._getDraftKey(location), expectedResult);
+    assert.equal(grStorage._getDraftKey(location), expectedResult);
     location.range = {
       start_character: 1,
       start_line: 1,
@@ -126,12 +123,12 @@
       end_line: 2,
     };
     expectedResult = 'draft:1234:5:my_source_file.js:123:1-1-1-2';
-    assert.equal(element._getDraftKey(location), expectedResult);
+    assert.equal(grStorage._getDraftKey(location), expectedResult);
   });
 
   test('exceeded quota disables storage', () => {
-    element._storage = mockStorage(true);
-    assert.isFalse(element._exceededQuota);
+    grStorage.storage = mockStorage(true);
+    assert.isFalse(grStorage.exceededQuota);
 
     const changeNum = 1234;
     const patchNum = 5;
@@ -143,37 +140,37 @@
       path,
       line,
     };
-    const key = element._getDraftKey(location);
-    element.setDraftComment(location, 'my comment');
-    assert.isTrue(element._exceededQuota);
-    assert.isNotOk(element._storage.getItem(key));
+    const key = grStorage._getDraftKey(location);
+    grStorage.setDraftComment(location, 'my comment');
+    assert.isTrue(grStorage.exceededQuota);
+    assert.isNotOk(grStorage.storage.getItem(key));
   });
 
   test('editable content items', () => {
-    const cleanupStub = sinon.stub(element, '_cleanupItems');
+    const cleanupStub = sinon.stub(grStorage, '_cleanupItems');
     const key = 'testKey';
-    const computedKey = element._getEditableContentKey(key);
+    const computedKey = grStorage._getEditableContentKey(key);
     // Key correctly computed.
     assert.equal(computedKey, 'editablecontent:testKey');
 
-    element.setEditableContentItem(key, 'my content');
+    grStorage.setEditableContentItem(key, 'my content');
 
     // Setting the draft stores it under the expected key.
-    let item = element._storage.getItem(computedKey);
+    let item = grStorage.storage.getItem(computedKey);
     assert.isOk(item);
     assert.equal(JSON.parse(item).message, 'my content');
     assert.isOk(JSON.parse(item).updated);
 
     // getEditableContentItem performs as expected.
-    item = element.getEditableContentItem(key);
+    item = grStorage.getEditableContentItem(key);
     assert.isOk(item);
     assert.equal(item.message, 'my content');
     assert.isOk(item.updated);
     assert.isTrue(cleanupStub.called);
 
     // eraseEditableContentItem performs as expected.
-    element.eraseEditableContentItem(key);
-    assert.isNotOk(element._storage.getItem(computedKey));
+    grStorage.eraseEditableContentItem(key);
+    assert.isNotOk(grStorage.storage.getItem(computedKey));
   });
 });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
index b3c0a85..885db2a 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -38,32 +38,23 @@
   {value: '😊', match: 'smile :)'},
   {value: '👍', match: 'thumbs up'},
   {value: '😄', match: 'laugh :D'},
-  {value: '🎉', match: 'party'},
-  {value: '😞', match: 'sad :('},
+  {value: '❤️', match: 'heart <3'},
   {value: '😂', match: "tears :')"},
-  {value: '🙏', match: 'pray'},
+  {value: '🎉', match: 'party'},
+  {value: '😎', match: 'cool |;)'},
+  {value: '😞', match: 'sad :('},
   {value: '😐', match: 'neutral :|'},
   {value: '😮', match: 'shock :O'},
-  {value: '👎', match: 'thumbs down'},
-  {value: '😎', match: 'cool |;)'},
+  {value: '🙏', match: 'pray'},
   {value: '😕', match: 'confused'},
   {value: '👌', match: 'ok'},
   {value: '🔥', match: 'fire'},
-  {value: '👊', match: 'fistbump'},
   {value: '💯', match: '100'},
-  {value: '💔', match: 'broken heart'},
-  {value: '🍺', match: 'beer'},
   {value: '✔', match: 'check'},
   {value: '😋', match: 'tongue'},
   {value: '😭', match: "crying :'("},
-  {value: '🐨', match: 'koala'},
   {value: '🤓', match: 'glasses'},
-  {value: '😆', match: 'grin'},
-  {value: '💩', match: 'poop'},
   {value: '😢', match: 'tear'},
-  {value: '😒', match: 'unamused'},
-  {value: '😉', match: 'wink ;)'},
-  {value: '🍷', match: 'wine'},
   {value: '😜', match: 'winking tongue ;)'},
 ];
 
@@ -121,7 +112,7 @@
   @property({type: Boolean})
   hideBorder = false;
 
-  /** Text input should be rendered in monspace font.  */
+  /** Text input should be rendered in monospace font.  */
   @property({type: Boolean})
   monospace = false;
 
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_html.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_html.ts
index 1f777aa..d55481b 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_html.ts
@@ -31,7 +31,8 @@
     :host(.code) {
       font-family: var(--monospace-font-family);
       font-size: var(--font-size-code);
-      line-height: var(--line-height-code);
+      /* usually 16px = 12px + 4px */
+      line-height: calc(var(--font-size-code) + var(--spacing-s));
       font-weight: var(--font-weight-normal);
     }
     #emojiSuggestions {
diff --git a/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts b/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
index fadbfa7..b876f2e 100644
--- a/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
+++ b/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
@@ -15,9 +15,8 @@
  * limitations under the License.
  */
 
-import {patchNumEquals} from '../../../utils/patch-set-util';
 import {ChangeInfo, PatchSetNum} from '../../../types/common';
-import {ParsedChangeInfo} from '../gr-rest-api-interface/gr-reviewer-updates-parser';
+import {ParsedChangeInfo} from '../../../types/types';
 
 type RevNumberToParentCountMap = {[revNumber: number]: number};
 
@@ -71,8 +70,8 @@
 
   getParentId(patchNum: PatchSetNum, parentIndex: number) {
     if (!this.change.revisions) return;
-    const rev = Object.values(this.change.revisions).find(rev =>
-      patchNumEquals(rev._number, patchNum)
+    const rev = Object.values(this.change.revisions).find(
+      rev => rev._number === patchNum
     );
     if (!rev || !rev.commit) return;
     return rev.commit.parents[parentIndex].commit;
diff --git a/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin.ts b/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin.ts
index abbcbc8..28b7a50 100644
--- a/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin.ts
+++ b/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin.ts
@@ -48,14 +48,6 @@
         'Size',
       ];
 
-      /**
-       * Returns the complement to the given column array
-       *
-       */
-      getComplementColumns(columns: string[]) {
-        return this.columnNames.filter(column => !columns.includes(column));
-      }
-
       isColumnHidden(columnToCheck?: string, columnsToDisplay?: string[]) {
         if (!columnsToDisplay || !columnToCheck) {
           return false;
@@ -103,7 +95,7 @@
        * @return If the column was renamed, returns a new array
        * with the corrected name. Otherwise, it returns the original param.
        */
-      getVisibleColumns(columns: string[]) {
+      renameProjectToRepoColumn(columns: string[]) {
         const projectIndex = columns.indexOf('Project');
         if (projectIndex === -1) {
           return columns;
@@ -120,7 +112,6 @@
 
 export interface ChangeTableMixinInterface {
   readonly columnNames: string[];
-  getComplementColumns(columns: string[]): string[];
   isColumnHidden(columnToCheck?: string, columnsToDisplay?: string[]): boolean;
   isColumnEnabled(
     column: string,
@@ -132,5 +123,5 @@
     config: ServerInfo,
     experiments: string[]
   ): string[];
-  getVisibleColumns(columns: string[]): string[];
+  renameProjectToRepoColumn(columns: string[]): string[];
 }
diff --git a/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin_test.js b/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin_test.js
index daf10ce..0d6b4ad 100644
--- a/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin_test.js
+++ b/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin_test.js
@@ -37,35 +37,6 @@
     element = basicFixture.instantiate();
   });
 
-  test('getComplementColumns', () => {
-    let columns = [
-      'Subject',
-      'Status',
-      'Owner',
-      'Assignee',
-      'Reviewers',
-      'Comments',
-      'Repo',
-      'Branch',
-      'Updated',
-      'Size',
-    ];
-    assert.deepEqual(element.getComplementColumns(columns), []);
-
-    columns = [
-      'Subject',
-      'Status',
-      'Assignee',
-      'Reviewers',
-      'Comments',
-      'Repo',
-      'Branch',
-      'Size',
-    ];
-    assert.deepEqual(element.getComplementColumns(columns),
-        ['Owner', 'Updated']);
-  });
-
   test('isColumnHidden', () => {
     const columnToCheck = 'Repo';
     let columnsToDisplay = [
@@ -92,15 +63,16 @@
     assert.isTrue(element.isColumnHidden(columnToCheck, columnsToDisplay));
   });
 
-  test('getVisibleColumns maps Project to Repo', () => {
+  test('renameProjectToRepoColumn maps Project to Repo', () => {
     const columns = [
       'Subject',
       'Status',
       'Owner',
     ];
-    assert.deepEqual(element.getVisibleColumns(columns), columns.slice(0));
+    assert.deepEqual(element.renameProjectToRepoColumn(columns),
+        columns.slice(0));
     assert.deepEqual(
-        element.getVisibleColumns(columns.concat(['Project'])),
+        element.renameProjectToRepoColumn(columns.concat(['Project'])),
         columns.slice(0).concat(['Repo']));
   });
 });
diff --git a/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts b/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts
index 08b18a3..75ad608 100644
--- a/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts
+++ b/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts
@@ -69,16 +69,16 @@
       // Handler for mouseenter event
       private mouseenterHandler?: (e: MouseEvent) => void;
 
-      // Hanlder for scrolling on window
+      // Handler for scrolling on window
       private readonly windowScrollHandler: () => void;
 
-      // Hanlder for showing the tooltip, will be attached to certain events
+      // Handler for showing the tooltip, will be attached to certain events
       private readonly showHandler: () => void;
 
-      // Hanlder for hiding the tooltip, will be attached to certain events
+      // Handler for hiding the tooltip, will be attached to certain events
       private readonly hideHandler: () => void;
 
-      // tslint:disable-next-line:no-any Required for constructor signature.
+      // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
       constructor(..._: any[]) {
         super();
         this.windowScrollHandler = () => this._handleWindowScroll();
diff --git a/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts b/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts
index 48a4848..57e034f 100644
--- a/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts
+++ b/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts
@@ -29,7 +29,7 @@
 // is used. To ensure that this import can't be avoided, the second parameter
 // is added. Usage example:
 // class Element extends IronFitMixin(PolymerElement, IronFitBehavior as IronFitBehavior)
-// The code 'IronFitBehavior as IronFitBehavior' required, becuase IronFitBehavior
+// The code 'IronFitBehavior as IronFitBehavior' required, because IronFitBehavior
 // defined as an object, not as IronFitBehavior instance.
 
 export const IronFitMixin = <T extends Constructor<PolymerElement>>(
@@ -38,4 +38,5 @@
 ): T & Constructor<IronFitBehavior> =>
   // TODO(TS): mixinBehaviors in some lib is returning: `new () => T` instead
   // which will fail the type check due to missing IronFitBehavior interface
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   mixinBehaviors([IronFitBehavior], superClass) as any;
diff --git a/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts b/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts
index 4884ec2..8429e38 100644
--- a/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts
+++ b/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts
@@ -38,4 +38,5 @@
   // TODO(TS): mixinBehaviors in some lib is returning: `new () => T`
   // instead which will fail the type check due to missing
   // IronOverlayBehavior interface
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   mixinBehaviors([IronOverlayBehavior], superClass) as any;
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
index 7aade93..ab85b87 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',
@@ -175,7 +175,7 @@
   VISIBLE_LINE = 'VISIBLE_LINE',
   NEXT_CHUNK = 'NEXT_CHUNK',
   PREV_CHUNK = 'PREV_CHUNK',
-  EXPAND_ALL_DIFF_CONTEXT = 'EXPAND_ALL_DIFF_CONTEXT',
+  TOGGLE_ALL_DIFF_CONTEXT = 'TOGGLE_ALL_DIFF_CONTEXT',
   NEXT_COMMENT_THREAD = 'NEXT_COMMENT_THREAD',
   PREV_COMMENT_THREAD = 'PREV_COMMENT_THREAD',
   EXPAND_ALL_COMMENT_THREADS = 'EXPAND_ALL_COMMENT_THREADS',
@@ -403,9 +403,9 @@
   'Go to previous diff chunk'
 );
 _describe(
-  Shortcut.EXPAND_ALL_DIFF_CONTEXT,
+  Shortcut.TOGGLE_ALL_DIFF_CONTEXT,
   ShortcutSection.DIFFS,
-  'Expand all diff context'
+  'Toggle all diff context'
 );
 _describe(
   Shortcut.NEXT_COMMENT_THREAD,
@@ -544,7 +544,7 @@
 }
 
 /**
- * Shortcut manager, holds all hosts, bindings and listners.
+ * Shortcut manager, holds all hosts, bindings and listeners.
  */
 export class ShortcutManager {
   private readonly activeHosts = new Map<PolymerElement, Map<string, string>>();
@@ -773,6 +773,7 @@
 
 interface IronA11yKeysMixinConstructor {
   // Note: this is needed to have same interface as other mixins
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   new (...args: any[]): IronA11yKeysBehavior;
 }
 /**
@@ -795,10 +796,10 @@
       _shortcut_v_key_last_pressed: number | null = null;
 
       @property({type: Object})
-      _shortcut_go_table: Map<string, string> = new Map();
+      _shortcut_go_table: Map<string, string> = new Map<string, string>();
 
       @property({type: Object})
-      _shortcut_v_table: Map<string, string> = new Map();
+      _shortcut_v_table: Map<string, string> = new Map<string, string>();
 
       Shortcut = Shortcut;
 
@@ -1007,6 +1008,7 @@
         const handler = this._shortcut_v_table.get(e.detail.key);
         if (handler) {
           // TODO(TS): should fix this
+          // eslint-disable-next-line @typescript-eslint/no-explicit-any
           (this as any)[handler](e);
         }
       }
@@ -1043,6 +1045,7 @@
         const handler = this._shortcut_go_table.get(e.detail.key);
         if (handler) {
           // TODO(TS): should fix this
+          // eslint-disable-next-line @typescript-eslint/no-explicit-any
           (this as any)[handler](e);
         }
       }
@@ -1066,6 +1069,7 @@
   return InternalKeyboardShortcutMixin(
     // TODO(TS): mixinBehaviors in some lib is returning: `new () => T` instead
     // which will fail the type check due to missing IronA11yKeysBehavior interface
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
     mixinBehaviors([IronA11yKeysBehavior], superClass) as any
   );
 };
diff --git a/polygerrit-ui/app/node_modules_licenses/BUILD b/polygerrit-ui/app/node_modules_licenses/BUILD
index 7652ddc..44b9811 100644
--- a/polygerrit-ui/app/node_modules_licenses/BUILD
+++ b/polygerrit-ui/app/node_modules_licenses/BUILD
@@ -12,10 +12,10 @@
         "licenses.ts",
     ],
     compiler = "//tools/node_tools:tsc_wrapped-bin",
-    node_modules = "@tools_npm//:node_modules",
     tsconfig = "tsconfig.json",
     deps = [
         "//tools/node_tools/node_modules_licenses:licenses-map",
+        "@tools_npm//@bazel/typescript",
         "@tools_npm//@types/node",
     ],
 )
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses.ts b/polygerrit-ui/app/node_modules_licenses/licenses.ts
index 81c2f5e..f03c7e6 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
@@ -249,6 +254,14 @@
     license: SharedLicenses.Polymer2017
   },
   {
+    name: "@types/resize-observer-browser",
+    license: {
+      name: 'DefinitelyTyped',
+      type: LicenseTypes.Mit,
+      packageLicenseFile: "LICENSE"
+    }
+  },
+  {
     name: "@webcomponents/shadycss",
     license: SharedLicenses.Polymer2017
   },
@@ -295,7 +308,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 acb0cf9..5e15990 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -25,12 +25,15 @@
     "@polymer/paper-tabs": "^3.1.0",
     "@polymer/paper-toggle-button": "^3.0.1",
     "@polymer/polymer": "^3.4.1",
+    "@types/resize-observer-browser": "^0.1.5",
     "@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",
+    "rxjs": "^6.6.2",
     "shadow-selection-polyfill": "^1.1.0"
   },
   "license": "Apache-2.0",
diff --git a/polygerrit-ui/app/polymer.json b/polygerrit-ui/app/polymer.json
index 11793e2..02ffd26 100644
--- a/polygerrit-ui/app/polymer.json
+++ b/polygerrit-ui/app/polymer.json
@@ -9,6 +9,10 @@
   ],
   "lint": {
     "rules": ["polymer-3"],
-    "ignoreWarnings": ["deprecated-dom-call"]
+    "ignoreWarnings": ["deprecated-dom-call"],
+    "filesToIgnore": [
+        "**/gr-plugin-rest-api.js",
+        "**/.cache/**/gr-plugin-rest-api.js"
+    ]
   }
 }
diff --git a/polygerrit-ui/app/samples/coverage-plugin.js b/polygerrit-ui/app/samples/coverage-plugin.js
index 9b2b687..8d321c7 100644
--- a/polygerrit-ui/app/samples/coverage-plugin.js
+++ b/polygerrit-ui/app/samples/coverage-plugin.js
@@ -41,7 +41,7 @@
   const coverageStyle = styleApi.css('background-color: #EF9B9B !important');
   const emptyStyle = styleApi.css('');
 
-  annotationApi.addLayer(context => {
+  annotationApi.setLayer(context => {
     if (Object.keys(coverageData).length === 0) {
       // Coverage data is not ready yet.
       return;
@@ -64,19 +64,13 @@
       }
     }
   }).enableToggleCheckbox('Display Coverage', checkbox => {
-    // Checkbox is attached so now add the notifier that will be controlled
-    // by the checkbox.
-    // Checkbox will only be added to the file diff page, in the top right
-    // section near the "Diff view".
-    annotationApi.addNotifier(notifyFunc => {
-      populateWithDummyData(coverageData);
-      checkbox.disabled = false;
-      checkbox.onclick = e => {
-        displayCoverage = e.target.checked;
-        Object.keys(coverageData).forEach(file => {
-          notifyFunc(file, 0, coverageData[file].totalLines, 'right');
-        });
-      };
-    });
+    populateWithDummyData(coverageData);
+    checkbox.disabled = false;
+    checkbox.onclick = e => {
+      displayCoverage = e.target.checked;
+      Object.keys(coverageData).forEach(file => {
+        annotationApi.notify(file, 0, coverageData[file].totalLines, 'right');
+      });
+    };
   });
-});
\ No newline at end of file
+});
diff --git a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.ts b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.ts
index e555ebe..5818003 100644
--- a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.ts
+++ b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.ts
@@ -15,14 +15,14 @@
  * limitations under the License.
  */
 import {getAccountDisplayName} from '../../utils/display-name-util';
-import {RestApiService} from '../../services/services/gr-rest-api/gr-rest-api';
+import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
 import {AccountInfo} from '../../types/common';
 
 export class GrEmailSuggestionsProvider {
-  constructor(private _restAPI: RestApiService) {}
+  constructor(private restAPI: RestApiService) {}
 
   getSuggestions(input: string) {
-    return this._restAPI.getSuggestedAccounts(`${input}`).then(accounts => {
+    return this.restAPI.getSuggestedAccounts(`${input}`).then(accounts => {
       if (!accounts) {
         return [];
       }
diff --git a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.js b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.js
index 7c40b7a..85c92d3 100644
--- a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.js
+++ b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.js
@@ -16,16 +16,11 @@
  */
 
 import '../../test/common-test-setup-karma.js';
-import '../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import {GrEmailSuggestionsProvider} from './gr-email-suggestions-provider.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-const basicFixture = fixtureFromTemplate(html`
-<gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`);
+import {appContext} from '../../services/app-context.js';
+import {stubRestApi} from '../../test/test-utils.js';
 
 suite('GrEmailSuggestionsProvider tests', () => {
-  let restAPI;
   let provider;
   const account1 = {
     name: 'Some name',
@@ -37,17 +32,14 @@
   };
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-    });
-    restAPI = basicFixture.instantiate();
-    provider = new GrEmailSuggestionsProvider(restAPI);
+    stubRestApi('getConfig').returns(Promise.resolve({}));
+    provider = new GrEmailSuggestionsProvider(appContext.restApiService);
   });
 
   test('getSuggestions', done => {
     const getSuggestedAccountsStub =
-        sinon.stub(restAPI, 'getSuggestedAccounts')
-            .returns(Promise.resolve([account1, account2]));
+        stubRestApi('getSuggestedAccounts').returns(
+            Promise.resolve([account1, account2]));
 
     provider.getSuggestions('Some input').then(res => {
       assert.deepEqual(res, [account1, account2]);
diff --git a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.ts b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.ts
index df77c76..ff113fb 100644
--- a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.ts
+++ b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.ts
@@ -15,14 +15,14 @@
  * limitations under the License.
  */
 
-import {RestApiService} from '../../services/services/gr-rest-api/gr-rest-api';
+import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
 import {GroupBaseInfo} from '../../types/common';
 
 export class GrGroupSuggestionsProvider {
-  constructor(private _restAPI: RestApiService) {}
+  constructor(private restAPI: RestApiService) {}
 
   getSuggestions(input: string) {
-    return this._restAPI.getSuggestedGroups(`${input}`).then(groups => {
+    return this.restAPI.getSuggestedGroups(`${input}`).then(groups => {
       if (!groups) {
         return [];
       }
diff --git a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.js b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.js
index 0939f76..3ce9d9d 100644
--- a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.js
+++ b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.js
@@ -16,16 +16,11 @@
  */
 
 import '../../test/common-test-setup-karma.js';
-import '../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import {GrGroupSuggestionsProvider} from './gr-group-suggestions-provider.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-const basicFixture = fixtureFromTemplate(html`
-<gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`);
+import {appContext} from '../../services/app-context.js';
+import {stubRestApi} from '../../test/test-utils.js';
 
 suite('GrGroupSuggestionsProvider tests', () => {
-  let restAPI;
   let provider;
   const group1 = {
     name: 'Some name',
@@ -38,16 +33,13 @@
   };
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-    });
-    restAPI = basicFixture.instantiate();
-    provider = new GrGroupSuggestionsProvider(restAPI);
+    stubRestApi('getConfig').returns(Promise.resolve({}));
+    provider = new GrGroupSuggestionsProvider(appContext.restApiService);
   });
 
   test('getSuggestions', done => {
     const getSuggestedAccountsStub =
-        sinon.stub(restAPI, 'getSuggestedGroups')
+        stubRestApi('getSuggestedGroups')
             .returns(Promise.resolve({
               'Some name': {id: 1},
               'Other name': {id: 3, url: 'abcd'},
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
index 6ab69bb..45116aa 100644
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
+++ b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
@@ -18,7 +18,7 @@
   getAccountDisplayName,
   getGroupDisplayName,
 } from '../../utils/display-name-util';
-import {RestApiService} from '../../services/services/gr-rest-api/gr-rest-api';
+import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
 import {
   AccountInfo,
   isReviewerAccountSuggestion,
@@ -73,13 +73,13 @@
     }
   }
 
-  private _initPromise?: Promise<void>;
+  private initPromise?: Promise<void>;
 
-  private _config?: ServerInfo;
+  private config?: ServerInfo;
 
-  private _loggedIn = false;
+  private loggedIn = false;
 
-  private _initialized = false;
+  private initialized = false;
 
   private constructor(
     private readonly _restAPI: RestApiService,
@@ -87,26 +87,25 @@
   ) {}
 
   init() {
-    if (this._initPromise) {
-      return this._initPromise;
+    if (this.initPromise) {
+      return this.initPromise;
     }
     const getConfigPromise = this._restAPI.getConfig().then(cfg => {
-      this._config = cfg;
+      this.config = cfg;
     });
     const getLoggedInPromise = this._restAPI.getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
+      this.loggedIn = loggedIn;
     });
-    this._initPromise = Promise.all([
-      getConfigPromise,
-      getLoggedInPromise,
-    ]).then(() => {
-      this._initialized = true;
-    });
-    return this._initPromise;
+    this.initPromise = Promise.all([getConfigPromise, getLoggedInPromise]).then(
+      () => {
+        this.initialized = true;
+      }
+    );
+    return this.initPromise;
   }
 
   getSuggestions(input: string): Promise<Suggestion[]> {
-    if (!this._initialized || !this._loggedIn) {
+    if (!this.initialized || !this.loggedIn) {
       return Promise.resolve([]);
     }
 
@@ -117,7 +116,7 @@
     if (isReviewerAccountSuggestion(suggestion)) {
       // Reviewer is an account suggestion from getChangeSuggestedReviewers.
       return {
-        name: getAccountDisplayName(this._config, suggestion.account),
+        name: getAccountDisplayName(this.config, suggestion.account),
         value: suggestion,
       };
     }
@@ -133,7 +132,7 @@
     if (isAccountSuggestions(suggestion)) {
       // Reviewer is an account suggestion from getSuggestedAccounts.
       return {
-        name: getAccountDisplayName(this._config, suggestion),
+        name: getAccountDisplayName(this.config, suggestion),
         value: {account: suggestion, count: 1},
       };
     }
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.js b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.js
index fe13c1c..d3cad45 100644
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.js
+++ b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.js
@@ -16,13 +16,9 @@
  */
 
 import '../../test/common-test-setup-karma.js';
-import '../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import {GrReviewerSuggestionsProvider, SUGGESTIONS_PROVIDERS_USERS_TYPES} from './gr-reviewer-suggestions-provider.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-const basicFixture = fixtureFromTemplate(html`
-<gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`);
+import {appContext} from '../../services/app-context.js';
+import {stubRestApi} from '../../test/test-utils.js';
 
 suite('GrReviewerSuggestionsProvider tests', () => {
   let _nextAccountId = 0;
@@ -51,7 +47,6 @@
   let suggestion1;
   let suggestion2;
   let suggestion3;
-  let restAPI;
   let provider;
 
   let redundantSuggestion1;
@@ -72,12 +67,8 @@
       },
     };
 
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(true); },
-      getConfig() { return Promise.resolve({}); },
-    });
+    stubRestApi('getConfig').returns(Promise.resolve({}));
 
-    restAPI = basicFixture.instantiate();
     change = {
       _number: 42,
       owner,
@@ -92,21 +83,23 @@
 
   suite('allowAnyUser set to false', () => {
     setup(done => {
-      provider = GrReviewerSuggestionsProvider.create(restAPI, change._number,
+      provider = GrReviewerSuggestionsProvider.create(
+          appContext.restApiService, change._number,
           SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER);
       provider.init().then(done);
     });
     suite('stubbed values for _getReviewerSuggestions', () => {
+      let getChangeSuggestedReviewersStub;
       setup(() => {
-        stub('gr-rest-api-interface', {
-          getChangeSuggestedReviewers() {
-            redundantSuggestion1 = {account: existingReviewer1};
-            redundantSuggestion2 = {account: existingReviewer2};
-            redundantSuggestion3 = {account: owner};
-            return Promise.resolve([redundantSuggestion1, redundantSuggestion2,
-              redundantSuggestion3, suggestion1, suggestion2, suggestion3]);
-          },
-        });
+        getChangeSuggestedReviewersStub =
+            stubRestApi('getChangeSuggestedReviewers').callsFake(() => {
+              redundantSuggestion1 = {account: existingReviewer1};
+              redundantSuggestion2 = {account: existingReviewer2};
+              redundantSuggestion3 = {account: owner};
+              return Promise.resolve([
+                redundantSuggestion1, redundantSuggestion2,
+                redundantSuggestion3, suggestion1, suggestion2, suggestion3]);
+            });
       });
 
       test('makeSuggestionItem formats account or group accordingly', () => {
@@ -137,7 +130,7 @@
           value: {account: {}},
         });
 
-        provider._config = {
+        provider.config = {
           user: {
             anonymous_coward_name: 'Anonymous Coward Name',
           },
@@ -186,26 +179,22 @@
       });
 
       test('getSuggestions short circuits when logged out', () => {
-        // API call is already stubbed.
-        const xhrSpy = restAPI.getChangeSuggestedReviewers;
-        provider._loggedIn = false;
+        provider.loggedIn = false;
         return provider.getSuggestions('').then(() => {
-          assert.isFalse(xhrSpy.called);
-          provider._loggedIn = true;
+          assert.isFalse(getChangeSuggestedReviewersStub.called);
+          provider.loggedIn = true;
           return provider.getSuggestions('').then(() => {
-            assert.isTrue(xhrSpy.called);
+            assert.isTrue(getChangeSuggestedReviewersStub.called);
           });
         });
       });
     });
 
     test('getChangeSuggestedReviewers is used', done => {
-      const suggestReviewerStub =
-          sinon.stub(restAPI, 'getChangeSuggestedReviewers')
-              .returns(Promise.resolve([]));
-      const suggestAccountStub =
-          sinon.stub(restAPI, 'getSuggestedAccounts')
-              .returns(Promise.resolve([]));
+      const suggestReviewerStub = stubRestApi('getChangeSuggestedReviewers')
+          .returns(Promise.resolve([]));
+      const suggestAccountStub = stubRestApi('getSuggestedAccounts')
+          .returns(Promise.resolve([]));
 
       provider.getSuggestions('').then(() => {
         assert.isTrue(suggestReviewerStub.calledOnce);
@@ -218,18 +207,17 @@
 
   suite('allowAnyUser set to true', () => {
     setup(done => {
-      provider = GrReviewerSuggestionsProvider.create(restAPI, change._number,
+      provider = GrReviewerSuggestionsProvider.create(
+          appContext.restApiService, change._number,
           SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY);
       provider.init().then(done);
     });
 
     test('getSuggestedAccounts is used', done => {
-      const suggestReviewerStub =
-          sinon.stub(restAPI, 'getChangeSuggestedReviewers')
-              .returns(Promise.resolve([]));
-      const suggestAccountStub =
-          sinon.stub(restAPI, 'getSuggestedAccounts')
-              .returns(Promise.resolve([]));
+      const suggestReviewerStub = stubRestApi('getChangeSuggestedReviewers')
+          .returns(Promise.resolve([]));
+      const suggestAccountStub = stubRestApi('getSuggestedAccounts')
+          .returns(Promise.resolve([]));
 
       provider.getSuggestions('').then(() => {
         assert.isFalse(suggestReviewerStub.called);
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index b249d16..0369ccf 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -19,12 +19,16 @@
 import {GrReporting} from './gr-reporting/gr-reporting_impl';
 import {EventEmitter} from './gr-event-interface/gr-event-interface_impl';
 import {Auth} from './gr-auth/gr-auth_impl';
+import {GrRestApiInterface} from '../elements/shared/gr-rest-api-interface/gr-rest-api-interface';
+import {ChangeService} from './change/change-service';
+import {ChecksService} from './checks/checks-service';
+import {GrJsApiInterface} from '../elements/shared/gr-js-api-interface/gr-js-api-interface-element';
 
 type ServiceName = keyof AppContext;
 type ServiceCreator<T> = () => T;
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
-const initializedServices: Map<ServiceName, any> = new Map();
+const initializedServices: Map<ServiceName, any> = new Map<ServiceName, any>();
 
 function getService<K extends ServiceName>(
   serviceName: K,
@@ -65,5 +69,9 @@
     reportingService: () => new GrReporting(appContext.flagsService),
     eventEmitter: () => new EventEmitter(),
     authService: () => new Auth(appContext.eventEmitter),
+    restApiService: () => new GrRestApiInterface(appContext.authService),
+    changeService: () => new ChangeService(),
+    checksService: () => new ChecksService(),
+    jsApiService: () => new GrJsApiInterface(),
   });
 }
diff --git a/polygerrit-ui/app/services/app-context.ts b/polygerrit-ui/app/services/app-context.ts
index c08ee7a..1f618fd 100644
--- a/polygerrit-ui/app/services/app-context.ts
+++ b/polygerrit-ui/app/services/app-context.ts
@@ -18,12 +18,20 @@
 import {EventEmitterService} from './gr-event-interface/gr-event-interface';
 import {ReportingService} from './gr-reporting/gr-reporting';
 import {AuthService} from './gr-auth/gr-auth';
+import {RestApiService} from './gr-rest-api/gr-rest-api';
+import {ChangeService} from './change/change-service';
+import {ChecksService} from './checks/checks-service';
+import {JsApiService} from '../elements/shared/gr-js-api-interface/gr-js-api-types';
 
 export interface AppContext {
   flagsService: FlagsService;
   reportingService: ReportingService;
   eventEmitter: EventEmitterService;
   authService: AuthService;
+  restApiService: RestApiService;
+  changeService: ChangeService;
+  checksService: ChecksService;
+  jsApiService: JsApiService;
 }
 
 /**
diff --git a/polygerrit-ui/app/services/change/change-model.ts b/polygerrit-ui/app/services/change/change-model.ts
new file mode 100644
index 0000000..e7472de
--- /dev/null
+++ b/polygerrit-ui/app/services/change/change-model.ts
@@ -0,0 +1,111 @@
+/**
+ * @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 {PatchSetNum} from '../../types/common';
+import {BehaviorSubject, combineLatest, Observable} from 'rxjs';
+import {
+  map,
+  filter,
+  withLatestFrom,
+  distinctUntilChanged,
+} from 'rxjs/operators';
+import {routerPatchNum$, routerState$} from '../router/router-model';
+import {
+  computeAllPatchSets,
+  computeLatestPatchNum,
+} from '../../utils/patch-set-util';
+import {ParsedChangeInfo} from '../../types/types';
+
+interface ChangeState {
+  change?: ParsedChangeInfo;
+}
+
+// TODO: Figure out how to best enforce immutability of all states. Use Immer?
+// Use DeepReadOnly?
+const initialState: ChangeState = {};
+
+const privateState$ = new BehaviorSubject(initialState);
+
+// Re-exporting as Observable so that you can only subscribe, but not emit.
+export const changeState$: Observable<ChangeState> = privateState$;
+
+// Must only be used by the change service or whatever is in control of this
+// model.
+export function updateState(change?: ParsedChangeInfo) {
+  const current = privateState$.getValue();
+  // We want to make it easy for subscribers to react to change changes, so we
+  // are explicitly emitting and additional `undefined` when the change number
+  // changes. So if you are subscribed to the latestPatchsetNumber for example,
+  // then you can rely on emissions even if the old and the new change have the
+  // same latestPatchsetNumber.
+  if (change !== undefined && current.change !== undefined) {
+    if (change._number !== current.change._number) {
+      privateState$.next({...current, change: undefined});
+    }
+  }
+  privateState$.next({...current, change});
+}
+
+/**
+ * If you depend on both, router and change state, then you want to filter out
+ * inconsistent state, e.g. router changeNum already updated, change not yet
+ * reset to undefined.
+ */
+export const changeAndRouterConsistent$ = combineLatest([
+  routerState$,
+  changeState$,
+]).pipe(
+  filter(([routerState, changeState]) => {
+    const changeNum = changeState.change?._number;
+    const routerChangeNum = routerState.changeNum;
+    return changeNum === undefined || changeNum === routerChangeNum;
+  }),
+  distinctUntilChanged()
+);
+
+export const change$ = changeState$.pipe(
+  map(changeState => changeState.change),
+  distinctUntilChanged()
+);
+
+export const changeNum$ = change$.pipe(
+  map(change => change?._number),
+  distinctUntilChanged()
+);
+
+export const latestPatchNum$ = change$.pipe(
+  map(change => computeLatestPatchNum(computeAllPatchSets(change))),
+  distinctUntilChanged()
+);
+
+/**
+ * Emits the current patchset number. If the route does not define the current
+ * patchset num, then this selector waits for the change to be defined and
+ * returns the number of the latest patchset.
+ *
+ * Note that this selector can emit a patchNum without the change being
+ * available!
+ */
+export const currentPatchNum$: Observable<
+  PatchSetNum | undefined
+> = changeAndRouterConsistent$.pipe(
+  withLatestFrom(routerPatchNum$, latestPatchNum$),
+  map(([_, routerPatchNum, latestPatchNum]) => {
+    return routerPatchNum || latestPatchNum;
+  }),
+  distinctUntilChanged()
+);
diff --git a/polygerrit-ui/app/services/change/change-service.ts b/polygerrit-ui/app/services/change/change-service.ts
new file mode 100644
index 0000000..c292fb5
--- /dev/null
+++ b/polygerrit-ui/app/services/change/change-service.ts
@@ -0,0 +1,41 @@
+/**
+ * @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 {routerChangeNum$} from '../router/router-model';
+import {updateState} from './change-model';
+import {ParsedChangeInfo} from '../../types/types';
+
+export class ChangeService {
+  constructor() {
+    // TODO: In the future we will want to make restApiService.getChangeDetail()
+    // calls from a switchMap() here. For now just make sure to invalidate the
+    // change when no changeNum is set.
+    routerChangeNum$.subscribe(changeNum => {
+      if (!changeNum) updateState(undefined);
+    });
+  }
+
+  /**
+   * This is a temporary indirection between change-view, which currently
+   * manages what the current change is, and the change-model, which will
+   * become the source of truth in the future. We will extract a substantial
+   * amount of code from change-view and move it into this change-service. This
+   * will take some time ...
+   */
+  updateChange(change: ParsedChangeInfo) {
+    updateState(change);
+  }
+}
diff --git a/polygerrit-ui/app/services/checks/checks-model.ts b/polygerrit-ui/app/services/checks/checks-model.ts
new file mode 100644
index 0000000..1c5b862
--- /dev/null
+++ b/polygerrit-ui/app/services/checks/checks-model.ts
@@ -0,0 +1,248 @@
+/**
+ * @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 {BehaviorSubject, Observable} from 'rxjs';
+import {
+  Action,
+  Category,
+  CheckResult,
+  CheckRun,
+  ChecksApiConfig,
+  LinkIcon,
+  RunStatus,
+} from '../../api/checks';
+import {distinctUntilChanged, map} from 'rxjs/operators';
+import {PatchSetNumber} from '../../types/common';
+
+// This is a convenience type for working with results, because when working
+// with a bunch of results you will typically also want to know about the run
+// properties. So you can just combine them with {...run, ...result}.
+export type RunResult = CheckRun & CheckResult;
+
+interface ChecksProviderState {
+  pluginName: string;
+  loading: boolean;
+  config?: ChecksApiConfig;
+  runs: CheckRun[];
+  actions: Action[];
+}
+
+interface ChecksState {
+  patchsetNumber?: PatchSetNumber;
+  providerNameToState: {
+    [name: string]: ChecksProviderState;
+  };
+}
+
+const initialState: ChecksState = {
+  providerNameToState: {},
+};
+
+const privateState$ = new BehaviorSubject(initialState);
+
+// Re-exporting as Observable so that you can only subscribe, but not emit.
+export const checksState$: Observable<ChecksState> = privateState$;
+
+export const checksPatchsetNumber$ = checksState$.pipe(
+  map(state => state.patchsetNumber),
+  distinctUntilChanged()
+);
+
+export const checksProviderState$ = checksState$.pipe(
+  map(state => state.providerNameToState),
+  distinctUntilChanged()
+);
+
+export const aPluginHasRegistered$ = checksProviderState$.pipe(
+  map(state => Object.keys(state).length > 0),
+  distinctUntilChanged()
+);
+
+export const someProvidersAreLoading$ = checksProviderState$.pipe(
+  map(state => {
+    return Object.values(state).some(providerState => providerState.loading);
+  }),
+  distinctUntilChanged()
+);
+
+export const allActions$ = checksProviderState$.pipe(
+  map(state => {
+    return Object.values(state).reduce(
+      (allActions: Action[], providerState: ChecksProviderState) => [
+        ...allActions,
+        ...providerState.actions,
+      ],
+      []
+    );
+  })
+);
+
+export const allRuns$ = checksProviderState$.pipe(
+  map(state => {
+    return Object.values(state).reduce(
+      (allRuns: CheckRun[], providerState: ChecksProviderState) => [
+        ...allRuns,
+        ...providerState.runs,
+      ],
+      []
+    );
+  })
+);
+
+export const checkToPluginMap$ = checksProviderState$.pipe(
+  map(state => {
+    const map = new Map<string, string>();
+    for (const [pluginName, providerState] of Object.entries(state)) {
+      for (const run of providerState.runs) {
+        map.set(run.checkName, pluginName);
+      }
+    }
+    return map;
+  })
+);
+
+export const allResults$ = checksProviderState$.pipe(
+  map(state => {
+    return Object.values(state)
+      .reduce(
+        (allResults: CheckResult[], providerState: ChecksProviderState) => [
+          ...allResults,
+          ...providerState.runs.reduce(
+            (results: CheckResult[], run: CheckRun) =>
+              results.concat(run.results ?? []),
+            []
+          ),
+        ],
+        []
+      )
+      .filter(r => r !== undefined);
+  })
+);
+
+// Must only be used by the checks service or whatever is in control of this
+// model.
+export function updateStateSetProvider(
+  pluginName: string,
+  config?: ChecksApiConfig
+) {
+  const nextState = {...privateState$.getValue()};
+  nextState.providerNameToState = {...nextState.providerNameToState};
+  nextState.providerNameToState[pluginName] = {
+    pluginName,
+    loading: false,
+    config,
+    runs: [],
+    actions: [],
+  };
+  privateState$.next(nextState);
+}
+
+// TODO(brohlfs): Remove all fake runs by end of January. They are just making
+// it easier to develop the UI and always see all the different types/states of
+// runs and results.
+
+export const fakeRun0: CheckRun = {
+  checkName: 'FAKE Error Finder',
+  results: [
+    {
+      category: Category.ERROR,
+      summary: 'I would like to point out this error: 1 is not equal to 2!',
+      links: [
+        {primary: true, url: 'https://www.google.com', icon: LinkIcon.EXTERNAL},
+      ],
+      tags: [{name: 'OBSOLETE'}, {name: 'E2E'}],
+    },
+    {
+      category: Category.ERROR,
+      summary: 'Running the mighty test has failed by crashing.',
+      links: [
+        {primary: true, url: 'https://www.google.com', icon: LinkIcon.EXTERNAL},
+      ],
+    },
+  ],
+  status: RunStatus.COMPLETED,
+};
+
+export const fakeRun1: CheckRun = {
+  checkName: 'FAKE Super Check',
+  labelName: 'Verified',
+  results: [
+    {
+      category: Category.WARNING,
+      summary: 'We think that you could improve this.',
+      message: `There is a lot to be said. A lot. I say, a lot.\n
+                So please keep reading.`,
+      tags: [{name: 'INTERRUPTED'}, {name: 'WINDOWS'}],
+    },
+  ],
+  status: RunStatus.RUNNING,
+};
+
+export const fakeRun2: CheckRun = {
+  checkName: 'FAKE Mega Analysis',
+  results: [
+    {
+      category: Category.INFO,
+      summary: 'This is looking a bit too large.',
+      message: 'We are still looking into how large exactly. Stay tuned.',
+      tags: [{name: 'FLAKY'}, {name: 'MAC-OS'}],
+    },
+  ],
+  status: RunStatus.COMPLETED,
+};
+
+export const fakeRun3: CheckRun = {
+  checkName: 'FAKE Critical Observations',
+  status: RunStatus.RUNNABLE,
+};
+
+export const fakeRun4: CheckRun = {
+  checkName: 'FAKE TODO Elimination',
+  status: RunStatus.COMPLETED,
+};
+
+export function updateStateSetLoading(pluginName: string) {
+  const nextState = {...privateState$.getValue()};
+  nextState.providerNameToState = {...nextState.providerNameToState};
+  nextState.providerNameToState[pluginName] = {
+    ...nextState.providerNameToState[pluginName],
+    loading: true,
+  };
+  privateState$.next(nextState);
+}
+
+export function updateStateSetResults(
+  pluginName: string,
+  runs: CheckRun[],
+  actions: Action[] = []
+) {
+  const nextState = {...privateState$.getValue()};
+  nextState.providerNameToState = {...nextState.providerNameToState};
+  nextState.providerNameToState[pluginName] = {
+    ...nextState.providerNameToState[pluginName],
+    loading: false,
+    runs: [...runs],
+    actions: [...actions],
+  };
+  privateState$.next(nextState);
+}
+
+export function updateStateSetPatchset(patchsetNumber?: PatchSetNumber) {
+  const nextState = {...privateState$.getValue()};
+  nextState.patchsetNumber = patchsetNumber;
+  privateState$.next(nextState);
+}
diff --git a/polygerrit-ui/app/services/checks/checks-service.ts b/polygerrit-ui/app/services/checks/checks-service.ts
new file mode 100644
index 0000000..fd1f810
--- /dev/null
+++ b/polygerrit-ui/app/services/checks/checks-service.ts
@@ -0,0 +1,147 @@
+/**
+ * @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 {
+  filter,
+  switchMap,
+  takeWhile,
+  throttleTime,
+  withLatestFrom,
+} from 'rxjs/operators';
+import {
+  ChangeData,
+  ChecksApiConfig,
+  ChecksProvider,
+  FetchResponse,
+  ResponseCode,
+} from '../../api/checks';
+import {change$, changeNum$, latestPatchNum$} from '../change/change-model';
+import {
+  updateStateSetLoading,
+  checkToPluginMap$,
+  updateStateSetProvider,
+  updateStateSetResults,
+  checksPatchsetNumber$,
+  updateStateSetPatchset,
+} from './checks-model';
+import {
+  BehaviorSubject,
+  combineLatest,
+  from,
+  Observable,
+  of,
+  Subject,
+  timer,
+} from 'rxjs';
+import {PatchSetNumber} from '../../types/common';
+
+export class ChecksService {
+  private readonly providers: {[name: string]: ChecksProvider} = {};
+
+  private readonly reloadSubjects: {[name: string]: Subject<void>} = {};
+
+  private checkToPluginMap = new Map<string, string>();
+
+  private readonly documentVisibilityChange$ = new BehaviorSubject(undefined);
+
+  constructor() {
+    checkToPluginMap$.subscribe(map => {
+      this.checkToPluginMap = map;
+    });
+    latestPatchNum$.subscribe(num => {
+      updateStateSetPatchset(num);
+    });
+    document.addEventListener('visibilitychange', () => {
+      this.documentVisibilityChange$.next(undefined);
+    });
+  }
+
+  setPatchset(num: PatchSetNumber) {
+    updateStateSetPatchset(num);
+  }
+
+  reload(pluginName: string) {
+    this.reloadSubjects[pluginName].next();
+  }
+
+  reloadAll() {
+    Object.keys(this.providers).forEach(key => this.reload(key));
+  }
+
+  reloadForCheck(checkName?: string) {
+    if (!checkName) return;
+    const plugin = this.checkToPluginMap.get(checkName);
+    if (plugin) this.reload(plugin);
+  }
+
+  register(
+    pluginName: string,
+    provider: ChecksProvider,
+    config: ChecksApiConfig
+  ) {
+    this.providers[pluginName] = provider;
+    this.reloadSubjects[pluginName] = new BehaviorSubject<void>(undefined);
+    updateStateSetProvider(pluginName, config);
+    const pollIntervalMs = (config?.fetchPollingIntervalSeconds ?? 60) * 1000;
+    // Various events should trigger fetching checks from the provider:
+    // 1. Change number and patchset number changes.
+    // 2. Specific reload requests.
+    // 3. Regular polling starting with an initial fetch right now.
+    // 4. A hidden Gerrit tab becoming visible.
+    combineLatest([
+      changeNum$,
+      checksPatchsetNumber$,
+      this.reloadSubjects[pluginName].pipe(throttleTime(1000)),
+      timer(0, pollIntervalMs),
+      this.documentVisibilityChange$,
+    ])
+      .pipe(
+        takeWhile(_ => !!this.providers[pluginName]),
+        filter(_ => document.visibilityState !== 'hidden'),
+        withLatestFrom(change$),
+        switchMap(
+          ([[changeNum, patchNum], change]): Observable<FetchResponse> => {
+            if (
+              !change ||
+              !changeNum ||
+              !patchNum ||
+              typeof patchNum !== 'number'
+            ) {
+              return of({
+                responseCode: ResponseCode.OK,
+                runs: [],
+              });
+            }
+            const data: ChangeData = {
+              changeNumber: changeNum,
+              patchsetNumber: patchNum,
+              repo: change.project,
+            };
+            updateStateSetLoading(pluginName);
+            return from(this.providers[pluginName].fetch(data));
+          }
+        )
+      )
+      .subscribe(response => {
+        updateStateSetResults(
+          pluginName,
+          response.runs ?? [],
+          response.actions
+        );
+      });
+  }
+}
diff --git a/polygerrit-ui/app/services/checks/checks-util.ts b/polygerrit-ui/app/services/checks/checks-util.ts
new file mode 100644
index 0000000..ea532ea
--- /dev/null
+++ b/polygerrit-ui/app/services/checks/checks-util.ts
@@ -0,0 +1,170 @@
+/**
+ * @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 {Action, Category, CheckRun, RunStatus} from '../../api/checks';
+import {assertNever} from '../../utils/common-util';
+
+export function worstCategory(run: CheckRun) {
+  if (hasResultsOf(run, Category.ERROR)) return Category.ERROR;
+  if (hasResultsOf(run, Category.WARNING)) return Category.WARNING;
+  if (hasResultsOf(run, Category.INFO)) return Category.INFO;
+  return undefined;
+}
+
+export function iconForCategory(category: Category | 'SUCCESS') {
+  switch (category) {
+    case Category.ERROR:
+      return 'error';
+    case Category.INFO:
+      return 'info-outline';
+    case Category.WARNING:
+      return 'warning';
+    case 'SUCCESS':
+      return 'check-circle-outline';
+    default:
+      assertNever(category, `Unsupported category: ${category}`);
+  }
+}
+
+enum PRIMARY_STATUS_ACTIONS {
+  RERUN = 'rerun',
+  RUN = 'run',
+  CANCEL = 'cancel',
+}
+
+export function toCanonicalAction(action: Action, status: RunStatus) {
+  let name = action.name.toLowerCase();
+  if (status === RunStatus.COMPLETED && (name === 'run' || name === 're-run')) {
+    name = PRIMARY_STATUS_ACTIONS.RERUN;
+  }
+  if (status === RunStatus.RUNNING && name === 'stop') {
+    name = PRIMARY_STATUS_ACTIONS.CANCEL;
+  }
+  return {...action, name};
+}
+
+export function primaryActionName(status: RunStatus) {
+  switch (status) {
+    case RunStatus.COMPLETED:
+      return PRIMARY_STATUS_ACTIONS.RERUN;
+    case RunStatus.RUNNABLE:
+      return PRIMARY_STATUS_ACTIONS.RUN;
+    case RunStatus.RUNNING:
+      return PRIMARY_STATUS_ACTIONS.CANCEL;
+    default:
+      assertNever(status, `Unsupported status: ${status}`);
+  }
+}
+
+export function primaryRunAction(run: CheckRun): Action | undefined {
+  return (run.actions ?? [])
+    .map(action => toCanonicalAction(action, run.status))
+    .filter(action => action.name === primaryActionName(run.status))[0];
+}
+
+export function iconForRun(run: CheckRun) {
+  if (run.status !== RunStatus.COMPLETED) {
+    return iconForStatus(run.status);
+  } else {
+    const category = worstCategory(run);
+    return category ? iconForCategory(category) : iconForStatus(run.status);
+  }
+}
+
+export function iconForStatus(status: RunStatus) {
+  switch (status) {
+    // Note that this is only for COMPLETED without results!
+    case RunStatus.COMPLETED:
+      return 'check-circle-outline';
+    case RunStatus.RUNNABLE:
+      return 'placeholder';
+    case RunStatus.RUNNING:
+      return 'timelapse';
+    default:
+      assertNever(status, `Unsupported status: ${status}`);
+  }
+}
+
+export function hasCompleted(run: CheckRun) {
+  return run.status === RunStatus.COMPLETED;
+}
+
+export function isRunning(run: CheckRun) {
+  return run.status === RunStatus.RUNNING;
+}
+
+export function isRunningOrHasCompleted(run: CheckRun) {
+  return run.status === RunStatus.COMPLETED || run.status === RunStatus.RUNNING;
+}
+
+export function hasCompletedWithoutResults(run: CheckRun) {
+  return run.status === RunStatus.COMPLETED && (run.results ?? []).length === 0;
+}
+
+export function hasCompletedWith(run: CheckRun, category: Category) {
+  return hasCompleted(run) && hasResultsOf(run, category);
+}
+
+export function hasResultsOf(run: CheckRun, category: Category) {
+  return getResultsOf(run, category).length > 0;
+}
+
+export function getResultsOf(run: CheckRun, category: Category) {
+  return (run.results ?? []).filter(r => r.category === category);
+}
+
+export function compareByWorstCategory(a: CheckRun, b: CheckRun) {
+  return level(worstCategory(b)) - level(worstCategory(a));
+}
+
+export function level(cat?: Category) {
+  if (!cat) return -1;
+  switch (cat) {
+    case Category.INFO:
+      return 0;
+    case Category.WARNING:
+      return 1;
+    case Category.ERROR:
+      return 2;
+  }
+}
+
+export interface ActionTriggeredEventDetail {
+  action: Action;
+  run?: CheckRun;
+}
+
+export type ActionTriggeredEvent = CustomEvent<ActionTriggeredEventDetail>;
+
+declare global {
+  interface HTMLElementEventMap {
+    'action-triggered': ActionTriggeredEvent;
+  }
+}
+
+export function fireActionTriggered(
+  target: EventTarget,
+  action: Action,
+  run?: CheckRun
+) {
+  target.dispatchEvent(
+    new CustomEvent('action-triggered', {
+      detail: {action, run},
+      composed: true,
+      bubbles: true,
+    })
+  );
+}
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index 047e9e0..878b8f7 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -24,7 +24,12 @@
  * @desc Experiment ids used in Gerrit.
  */
 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',
+  // Note that this flag is not supposed to be used by Gerrit itself, but can
+  // be used by plugins. The new Checks UI will show up, if a plugin registers
+  // with the new Checks plugin API.
+  CI_REBOOT_CHECKS = 'UiFeature__ci_reboot_checks',
+  NEW_CHANGE_SUMMARY_UI = 'UiFeature__new_change_summary_ui',
+  PORTING_COMMENTS = 'UiFeature__porting_comments',
+  NEW_IMAGE_DIFF_UI = 'UiFeature__new_image_diff_ui',
+  COMMENT_CONTEXT = 'UiFeature__comment_context',
 }
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
index 8fe7c35..6fadfde 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
@@ -61,26 +61,26 @@
 
   static CREDS_EXPIRED_MSG = 'Credentials expired.';
 
-  private _authCheckPromise?: Promise<Response>;
+  private authCheckPromise?: Promise<Response>;
 
   private _last_auth_check_time: number = Date.now();
 
   private _status = AuthStatus.UNDETERMINED;
 
-  private _retriesLeft = MAX_GET_TOKEN_RETRIES;
+  private retriesLeft = MAX_GET_TOKEN_RETRIES;
 
-  private _cachedTokenPromise: Promise<Token | null> | null = null;
+  private cachedTokenPromise: Promise<Token | null> | null = null;
 
-  private _type?: AuthType;
+  private type?: AuthType;
 
-  private _defaultOptions: AuthRequestInit = {};
+  private defaultOptions: AuthRequestInit = {};
 
-  private _getToken: GetTokenCallback;
+  private getToken: GetTokenCallback;
 
   public eventEmitter: EventEmitterService;
 
   constructor(eventEmitter: EventEmitterService) {
-    this._getToken = () => Promise.resolve(this._cachedTokenPromise);
+    this.getToken = () => Promise.resolve(this.cachedTokenPromise);
     this.eventEmitter = eventEmitter;
   }
 
@@ -93,15 +93,15 @@
    */
   authCheck(): Promise<boolean> {
     if (
-      !this._authCheckPromise ||
+      !this.authCheckPromise ||
       Date.now() - this._last_auth_check_time > MAX_AUTH_CHECK_WAIT_TIME_MS
     ) {
       // Refetch after last check expired
-      this._authCheckPromise = fetch(`${this.baseUrl}/auth-check`);
+      this.authCheckPromise = fetch(`${this.baseUrl}/auth-check`);
       this._last_auth_check_time = Date.now();
     }
 
-    return this._authCheckPromise
+    return this.authCheckPromise
       .then(res => {
         // auth-check will return 204 if authed
         // treat the rest as unauthed
@@ -115,14 +115,14 @@
       })
       .catch(() => {
         this._setStatus(AuthStatus.ERROR);
-        // Reset _authCheckPromise to avoid caching the failed promise
-        this._authCheckPromise = undefined;
+        // Reset authCheckPromise to avoid caching the failed promise
+        this.authCheckPromise = undefined;
         return false;
       });
   }
 
   clearCache() {
-    this._authCheckPromise = undefined;
+    this.authCheckPromise = undefined;
   }
 
   private _setStatus(status: AuthStatus) {
@@ -149,15 +149,15 @@
    * Enable cross-domain authentication using OAuth access token.
    */
   setup(getToken: GetTokenCallback, defaultOptions: DefaultAuthOptions) {
-    this._retriesLeft = MAX_GET_TOKEN_RETRIES;
+    this.retriesLeft = MAX_GET_TOKEN_RETRIES;
     if (getToken) {
-      this._type = AuthType.ACCESS_TOKEN;
-      this._cachedTokenPromise = null;
-      this._getToken = getToken;
+      this.type = AuthType.ACCESS_TOKEN;
+      this.cachedTokenPromise = null;
+      this.getToken = getToken;
     }
-    this._defaultOptions = {};
+    this.defaultOptions = {};
     if (defaultOptions) {
-      this._defaultOptions.credentials = defaultOptions.credentials;
+      this.defaultOptions.credentials = defaultOptions.credentials;
     }
   }
 
@@ -167,10 +167,10 @@
   fetch(url: string, opt_options?: AuthRequestInit): Promise<Response> {
     const options: AuthRequestInitWithHeaders = {
       headers: new Headers(),
-      ...this._defaultOptions,
+      ...this.defaultOptions,
       ...opt_options,
     };
-    if (this._type === AuthType.ACCESS_TOKEN) {
+    if (this.type === AuthType.ACCESS_TOKEN) {
       return this._getAccessToken().then(accessToken =>
         this._fetchWithAccessToken(url, options, accessToken)
       );
@@ -224,17 +224,17 @@
   }
 
   private _getAccessToken(): Promise<string | null> {
-    if (!this._cachedTokenPromise) {
-      this._cachedTokenPromise = this._getToken();
+    if (!this.cachedTokenPromise) {
+      this.cachedTokenPromise = this.getToken();
     }
-    return this._cachedTokenPromise.then(token => {
+    return this.cachedTokenPromise.then(token => {
       if (this._isTokenValid(token)) {
-        this._retriesLeft = MAX_GET_TOKEN_RETRIES;
+        this.retriesLeft = MAX_GET_TOKEN_RETRIES;
         return token.access_token;
       }
-      if (this._retriesLeft > 0) {
-        this._retriesLeft--;
-        this._cachedTokenPromise = null;
+      if (this.retriesLeft > 0) {
+        this.retriesLeft--;
+        this.cachedTokenPromise = null;
         return this._getAccessToken();
       }
       // Fall back to anonymous access.
diff --git a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.ts b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.ts
index d59a022..e540029 100644
--- a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.ts
+++ b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.ts
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
 export type EventCallback = (...args: any) => void;
 export type UnsubscribeMethod = () => void;
 
@@ -51,11 +52,13 @@
    *
    * @returns true if the event had listeners, false otherwise.
    */
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   emit(eventName: string, detail: any): boolean;
 
   /**
    * Alias to emit.
    */
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   dispatch(eventName: string, detail: any): boolean;
 
   /**
diff --git a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_impl.ts b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_impl.ts
index 72afbda..d8c5d77 100644
--- a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_impl.ts
+++ b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_impl.ts
@@ -66,6 +66,7 @@
    * Attach event handler only once. Automatically removed.
    */
   once(eventName: string, cb: EventCallback): UnsubscribeMethod {
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
     const onceWrapper = (...args: any[]) => {
       cb(...args);
       this.off(eventName, onceWrapper);
@@ -96,6 +97,7 @@
    *
    * @returns true if the event had listeners, false otherwise.
    */
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   emit(eventName: string, detail: any): boolean {
     const listeners = this._listenersMap.get(eventName) || [];
     for (const listener of listeners) {
@@ -111,6 +113,7 @@
   /**
    * Alias to emit.
    */
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   dispatch(eventName: string, detail: any): boolean {
     return this.emit(eventName, detail);
   }
@@ -124,7 +127,7 @@
     if (eventName) {
       this._listenersMap.set(eventName, []);
     } else {
-      this._listenersMap = new Map();
+      this._listenersMap = new Map<string, EventCallback[]>();
     }
   }
 }
diff --git a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.js b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.js
index 32590e0..6ce5eea 100644
--- a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.js
+++ b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.js
@@ -16,11 +16,8 @@
  */
 
 import '../../test/common-test-setup-karma.js';
-import '../../elements/shared/gr-js-api-interface/gr-js-api-interface.js';
 import {EventEmitter} from './gr-event-interface_impl.js';
 
-const basicFixture = fixtureFromElement('gr-js-api-interface');
-
 suite('gr-event-interface tests', () => {
   let gerrit;
   setup(() => {
@@ -29,7 +26,6 @@
 
   suite('test on Gerrit', () => {
     setup(() => {
-      basicFixture.instantiate();
       gerrit.removeAllListeners();
     });
 
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index 743e0f4..4196513 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -15,10 +15,10 @@
  * limitations under the License.
  */
 
-export type EventValue = string | number | {error?: Error};
+import {NumericChangeId} from '../../types/common';
+import {EventDetails} from '../../api/reporting';
 
-// TODO(dmfilippov): TS-fix-any use more specific type instead if possible
-export type EventDetails = any;
+export type EventValue = string | number | {error?: Error};
 
 export interface Timer {
   reset(): this;
@@ -50,6 +50,7 @@
   reportExtension(name: string): void;
   pluginLoaded(name: string): void;
   pluginsLoaded(pluginsList?: string[]): void;
+  error(err: unknown, reporter?: string, details?: EventDetails): void;
   /**
    * Reset named timer.
    */
@@ -60,7 +61,7 @@
   timeEnd(name: string, eventDetails?: EventDetails): void;
   /**
    * Reports just line timeEnd, but additionally reports an average given a
-   * denominator and a separate reporiting name for the average.
+   * denominator and a separate reporting name for the average.
    *
    * @param name Timing name.
    * @param averageName Average timing name.
@@ -73,7 +74,7 @@
     denominator: number
   ): void;
   /**
-   * Get a timer object to for reporing a user timing. The start time will be
+   * Get a timer object for reporting a user timing. The start time will be
    * the time that the object has been created, and the end time will be the
    * time that the "end" method is called on the object.
    */
@@ -86,12 +87,23 @@
    */
   reportRpcTiming(anonymizedUrl: string, elapsed: number): void;
   reportLifeCycle(eventName: string, details?: EventDetails): void;
+
+  /**
+   * Use this method, if you want to check/count how often a certain code path
+   * is executed. For example you can use this method to prove that certain code
+   * paths are dead: Add reportExecution(), check the logs a week later, then
+   * safely remove the coe.
+   *
+   * Every execution is only reported once per session.
+   */
+  reportExecution(id: string, details: EventDetails): void;
   reportInteraction(eventName: string, details?: EventDetails): void;
   /**
-   * A draft interaction was started. Update the time-betweeen-draft-actions
+   * A draft interaction was started. Update the time-between-draft-actions
    * timer.
    */
   recordDraftInteraction(): void;
   reportErrorDialog(message: string): void;
   setRepoName(repoName: string): void;
+  setChangeId(changeId: NumericChangeId): void;
 }
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 f3aacdf..631a4e0 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -16,13 +16,10 @@
  */
 import {AppContext} from '../app-context';
 import {FlagsService} from '../flags/flags';
-import {
-  EventDetails,
-  EventValue,
-  ReportingService,
-  Timer,
-} from './gr-reporting';
+import {EventValue, ReportingService, Timer} from './gr-reporting';
 import {hasOwnProperty} from '../../utils/common-util';
+import {NumericChangeId} from '../../types/common';
+import {EventDetails} from '../../api/reporting';
 
 // Latency reporting constants.
 
@@ -44,6 +41,7 @@
     EXTENSION_DETECTED: 'Extension detected',
     PLUGINS_INSTALLED: 'Plugins installed',
     VISIBILITY: 'Visibility',
+    EXECUTION: 'Execution',
   },
 };
 
@@ -114,7 +112,22 @@
 
 export function initErrorReporter(appContext: AppContext) {
   const reportingService = appContext.reportingService;
-  // TODO(dmfilippo): TS-fix-any oldOnError - define correct type
+
+  const normalizeError = (err: Error | unknown) => {
+    if (err instanceof Error) {
+      return err;
+    }
+    let msg = '';
+    if (typeof err === 'string') {
+      msg += err;
+    } else {
+      msg += JSON.stringify(err);
+    }
+    const error = new Error(msg);
+    error.stack = 'unknown';
+    return error;
+  };
+  // TODO(dmfilippov): TS-fix-any oldOnError - define correct type
   const onError = function (
     oldOnError: Function,
     msg: Event | string,
@@ -127,32 +140,19 @@
       oldOnError(msg, url, line, column, error);
     }
     if (error) {
-      line = line || error.lineNumber;
-      column = column || error.columnNumber;
-      let shortenedErrorStack = msg;
-      if (error.stack) {
-        const errorStackLines = error.stack.split('\n');
-        shortenedErrorStack = errorStackLines
-          .slice(0, Math.min(3, errorStackLines.length))
-          .join('\n');
-      }
-      msg = shortenedErrorStack || error.toString();
+      line = line ?? error.lineNumber;
+      column = column ?? error.columnNumber;
     }
-    const payload = {
-      url,
+    reportingService.error(normalizeError(error), 'onError', {
       line,
       column,
-      error,
-    };
-    reportingService.reporter(
-      ERROR.TYPE,
-      ERROR.CATEGORY.EXCEPTION,
-      `${msg}`,
-      payload
-    );
+      url,
+      msg,
+    });
     return true;
   };
   // TODO(dmfilippov): TS-fix-any unclear what is context
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   const catchErrors = function (opt_context?: any) {
     const context = opt_context || window;
     const oldOnError = context.onerror;
@@ -168,16 +168,7 @@
     context.addEventListener(
       'unhandledrejection',
       (e: PromiseRejectionEvent) => {
-        const msg = e.reason.message;
-        const payload = {
-          error: e.reason,
-        };
-        reportingService.reporter(
-          ERROR.TYPE,
-          ERROR.CATEGORY.EXCEPTION,
-          msg,
-          payload
-        );
+        reportingService.error(normalizeError(e.reason), 'unhandledrejection');
       }
     );
   };
@@ -276,6 +267,7 @@
   eventStart: number;
   eventDetails?: string;
   repoName?: string;
+  changeId?: string;
   inBackgroundTab?: boolean;
   enabledExperiments?: string;
 }
@@ -300,15 +292,23 @@
 
   private readonly _baselines = STARTUP_TIMERS;
 
-  private _reportRepoName: string | undefined;
+  private reportRepoName: string | undefined;
 
-  private _timers: {timeBetweenDraftActions: Timer | null} = {
+  private reportChangeId: NumericChangeId | undefined;
+
+  private timers: {timeBetweenDraftActions: Timer | null} = {
     timeBetweenDraftActions: null,
   };
 
-  private _pending: PendingReportInfo[] = [];
+  private pending: PendingReportInfo[] = [];
 
-  private _slowRpcList: SlowRpcCall[] = [];
+  private slowRpcList: SlowRpcCall[] = [];
+
+  /**
+   * Keeps track of which ids were already reported to have been executed.
+   * Execution ids should only be reported once per session.
+   */
+  private executionReported = new Set<string>();
 
   public readonly hiddenDurationTimer = new HiddenDurationTimer();
 
@@ -321,7 +321,7 @@
   }
 
   private get slowRpcSnapshot() {
-    return (this._slowRpcList || []).slice();
+    return (this.slowRpcList || []).slice();
   }
 
   private _arePluginsLoaded() {
@@ -360,20 +360,22 @@
       eventDetails
     );
     if (type === ERROR.TYPE && category === ERROR.CATEGORY.EXCEPTION) {
-      console.error((eventValue && (eventValue as any).error) || eventName);
+      console.error(
+        (typeof eventValue === 'object' && eventValue.error) || eventName
+      );
     }
 
     // We report events immediately when metrics plugin is loaded
-    if (this._isMetricsPluginLoaded() && !this._pending.length) {
+    if (this._isMetricsPluginLoaded() && !this.pending.length) {
       this._reportEvent(eventInfo, noLog);
     } else {
       // We cache until metrics plugin is loaded
-      this._pending.push([eventInfo, noLog]);
+      this.pending.push([eventInfo, noLog]);
       if (this._isMetricsPluginLoaded()) {
-        this._pending.forEach(([eventInfo, opt_noLog]) => {
+        this.pending.forEach(([eventInfo, opt_noLog]) => {
           this._reportEvent(eventInfo, opt_noLog);
         });
-        this._pending = [];
+        this.pending = [];
       }
     }
   }
@@ -415,8 +417,11 @@
       eventInfo.eventDetails = JSON.stringify(eventDetails);
     }
 
-    if (this._reportRepoName) {
-      eventInfo.repoName = this._reportRepoName;
+    if (this.reportRepoName) {
+      eventInfo.repoName = this.reportRepoName;
+    }
+    if (this.reportChangeId) {
+      eventInfo.changeId = `${this.reportChangeId}`;
     }
 
     const isInBackgroundTab = document.visibilityState === 'hidden';
@@ -496,9 +501,10 @@
     this.time(TIMER.DIFF_VIEW_DISPLAYED);
     this.time(TIMER.DIFF_VIEW_LOAD_FULL);
     this.time(TIMER.FILE_LIST_DISPLAYED);
-    this._reportRepoName = undefined;
+    this.reportRepoName = undefined;
+    this.reportChangeId = undefined;
     // reset slow rpc list since here start page loads which report these rpcs
-    this._slowRpcList = [];
+    this.slowRpcList = [];
     this.hiddenDurationTimer.reset();
   }
 
@@ -645,7 +651,7 @@
     if (baseTime !== 0) {
       window.performance.measure(name, `${name}-start`);
     } else {
-      // Microsft Edge does not handle the 2nd param correctly
+      // Microsoft Edge does not handle the 2nd param correctly
       // (if undefined).
       window.performance.measure(name);
     }
@@ -653,7 +659,7 @@
 
   /**
    * Reports just line timeEnd, but additionally reports an average given a
-   * denominator and a separate reporiting name for the average.
+   * denominator and a separate reporting name for the average.
    *
    * @param name Timing name.
    * @param averageName Average timing name.
@@ -697,7 +703,7 @@
   }
 
   /**
-   * Get a timer object to for reporing a user timing. The start time will be
+   * Get a timer object to for reporting a user timing. The start time will be
    * the time that the object has been created, and the end time will be the
    * time that the "end" method is called on the object.
    */
@@ -759,7 +765,7 @@
       true
     );
     if (elapsed >= SLOW_RPC_THRESHOLD) {
-      this._slowRpcList.push({anonymizedUrl, elapsed});
+      this.slowRpcList.push({anonymizedUrl, elapsed});
     }
   }
 
@@ -785,18 +791,31 @@
     );
   }
 
+  reportExecution(id: string, details: EventDetails) {
+    if (this.executionReported.has(id)) return;
+    this.executionReported.add(id);
+    this.reporter(
+      LIFECYCLE.TYPE,
+      LIFECYCLE.CATEGORY.EXECUTION,
+      id,
+      undefined,
+      details,
+      false
+    );
+  }
+
   /**
-   * A draft interaction was started. Update the time-betweeen-draft-actions
+   * A draft interaction was started. Update the time-between-draft-actions
    * timer.
    */
   recordDraftInteraction() {
     // If there is no timer defined, then this is the first interaction.
     // Set up the timer so that it's ready to record the intervening time when
     // called again.
-    const timer = this._timers.timeBetweenDraftActions;
+    const timer = this.timers.timeBetweenDraftActions;
     if (!timer) {
       // Create a timer with a maximum length.
-      this._timers.timeBetweenDraftActions = this.getTimer(
+      this.timers.timeBetweenDraftActions = this.getTimer(
         DRAFT_ACTION_TIMER
       ).withMaximum(DRAFT_ACTION_TIMER_MAX);
       return;
@@ -806,6 +825,19 @@
     timer.end().reset();
   }
 
+  error(error: Error, errorSource?: string, details?: EventDetails) {
+    const eventDetails = details ?? {};
+    const message = `${errorSource ? errorSource + ': ' : ''}${error.message}`;
+
+    this.reporter(
+      ERROR.TYPE,
+      ERROR.CATEGORY.EXCEPTION,
+      message,
+      {error},
+      {...eventDetails, stack: error.stack}
+    );
+  }
+
   reportErrorDialog(message: string) {
     this.reporter(
       ERROR.TYPE,
@@ -816,7 +848,11 @@
   }
 
   setRepoName(repoName: string) {
-    this._reportRepoName = repoName;
+    this.reportRepoName = repoName;
+  }
+
+  setChangeId(changeId: NumericChangeId) {
+    this.reportChangeId = changeId;
   }
 }
 
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 924ddd9..484ce45 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 import {ReportingService, Timer} from './gr-reporting';
+import {EventDetails} from '../../api/reporting';
 
 export class MockTimer implements Timer {
   end(): this {
@@ -30,6 +31,10 @@
   }
 }
 
+const log = function (msg: string) {
+  console.info(`ReportingMock.${msg}`);
+};
+
 export const grReportingMock: ReportingService = {
   appStarted: () => {},
   beforeLocationChanged: () => {},
@@ -43,18 +48,33 @@
   getTimer: () => {
     return new MockTimer();
   },
-  locationChanged: () => {},
-  onVisibilityChange: () => {},
+  locationChanged: (page: string) => {
+    log(`locationChanged: ${page}`);
+  },
+  onVisibilityChange: () => {
+    log('onVisibilityChange');
+  },
   pluginLoaded: () => {},
   pluginsLoaded: () => {},
   recordDraftInteraction: () => {},
   reporter: () => {},
-  reportErrorDialog: () => {},
+  reportErrorDialog: (message: string) => {
+    log(`reportErrorDialog: ${message}`);
+  },
+  error: () => {
+    log('error');
+  },
+  reportExecution: (id: string, details: EventDetails) => {
+    log(`reportExecution '${id}': ${JSON.stringify(details)}`);
+  },
   reportExtension: () => {},
-  reportInteraction: () => {},
+  reportInteraction: (eventName: string, details?: EventDetails) => {
+    log(`reportInteraction '${eventName}': ${JSON.stringify(details)}`);
+  },
   reportLifeCycle: () => {},
   reportRpcTiming: () => {},
   setRepoName: () => {},
+  setChangeId: () => {},
   time: () => {},
   timeEnd: () => {},
   timeEndWithAverage: () => {},
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
index 08e4a55..6e56ab1 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
@@ -463,23 +463,14 @@
       const error = new Error('bar');
       error.stack = undefined;
       emulateThrow('bar', 'http://url', 4, 2, error);
-      assert.isTrue(reporter.calledWith('error', 'exception', 'bar'));
-      const payload = reporter.lastCall.args[3];
-      assert.deepEqual(payload, {
-        url: 'http://url',
-        line: 4,
-        column: 2,
-        error,
-      });
+      assert.isTrue(reporter.calledWith('error', 'exception', 'onError: bar'));
     });
 
-    test('is reported with 3 lines of stack', () => {
+    test('is reported with stack', () => {
       const error = new Error('bar');
       emulateThrow('bar', 'http://url', 4, 2, error);
-      const expectedStack = error.stack.split('\n').slice(0, 3)
-          .join('\n');
-      assert.isTrue(reporter.calledWith('error', 'exception',
-          expectedStack));
+      const eventDetails = reporter.lastCall.args[4];
+      assert.equal(error.stack, eventDetails.stack);
     });
 
     test('prevent default event handler', () => {
@@ -487,12 +478,10 @@
     });
 
     test('unhandled rejection', () => {
-      fakeWindow.handlers['unhandledrejection']({
-        reason: {
-          message: 'bar',
-        },
-      });
-      assert.isTrue(reporter.calledWith('error', 'exception', 'bar'));
+      const newError = new Error('bar');
+      fakeWindow.handlers['unhandledrejection']({reason: newError});
+      assert.isTrue(reporter.calledWith('error', 'exception',
+          'unhandledrejection: bar'));
     });
   });
 });
diff --git a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
similarity index 84%
rename from polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
rename to polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
index 950619b..4742e65 100644
--- a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
@@ -15,96 +15,99 @@
  * limitations under the License.
  */
 
+import {HttpMethod} from '../../constants/constants';
 import {
+  AccountCapabilityInfo,
   AccountDetailInfo,
   AccountExternalIdInfo,
+  AccountId,
   AccountInfo,
-  NumericChangeId,
-  ServerInfo,
-  ProjectInfo,
-  AccountCapabilityInfo,
-  SuggestedReviewerInfo,
-  GroupNameToGroupInfoMap,
-  ParsedJSON,
-  PatchSetNum,
-  RequestPayload,
-  PreferencesInput,
-  DiffPreferencesInfo,
-  EditPreferencesInfo,
-  DiffPreferenceInput,
-  SshKeyInfo,
-  RepoName,
-  BranchName,
-  BranchInput,
-  TagInput,
-  GpgKeysInput,
-  GpgKeyId,
-  GpgKeyInfo,
-  PreferencesInfo,
-  EmailInfo,
-  ProjectAccessInfo,
-  CapabilityInfoMap,
-  ProjectAccessInput,
-  ChangeInfo,
-  ProjectInfoWithName,
-  GroupId,
-  GroupInfo,
-  GroupOptionsInput,
+  ActionNameToActionInfoMap,
+  Base64FileContent,
+  BlameInfo,
   BranchInfo,
-  ConfigInfo,
-  ReviewInput,
-  EditInfo,
+  BranchInput,
+  BranchName,
+  CapabilityInfoMap,
   ChangeId,
-  DashboardInfo,
-  ProjectAccessInfoMap,
-  IncludedInInfo,
-  RobotCommentInfo,
+  ChangeInfo,
+  ChangeMessageId,
   CommentInfo,
-  PathToCommentsInfoMap,
-  PathToRobotCommentsInfoMap,
   CommentInput,
-  GroupInput,
-  PluginInfo,
-  DocResult,
+  CommitInfo,
+  ConfigInfo,
+  ConfigInput,
   ContributorAgreementInfo,
   ContributorAgreementInput,
-  Password,
-  ProjectWatchInfo,
-  NameToProjectInfoMap,
-  ProjectInput,
-  AccountId,
-  ChangeMessageId,
-  GroupAuditEventInfo,
-  EncodedGroupId,
-  Base64FileContent,
-  UrlEncodedCommentId,
-  TagInfo,
-  GitRef,
-  ConfigInput,
-  RelatedChangesInfo,
-  SubmittedTogetherInfo,
-  EmailAddress,
-  FixId,
-  FilePathToDiffInfoMap,
-  DiffInfo,
-  BlameInfo,
-  PatchRange,
-  ImagesForDiff,
-  ActionNameToActionInfoMap,
-  RevisionId,
-  GroupName,
   DashboardId,
-  HashtagsInput,
-  Hashtag,
+  DashboardInfo,
+  DiffPreferenceInput,
+  DocResult,
+  EditInfo,
+  EditPreferencesInfo,
+  EmailAddress,
+  EmailInfo,
+  EncodedGroupId,
   FileNameToFileInfoMap,
-  TopMenuEntryInfo,
+  FilePathToDiffInfoMap,
+  FixId,
+  GitRef,
+  GpgKeyId,
+  GpgKeyInfo,
+  GpgKeysInput,
+  GroupAuditEventInfo,
+  GroupId,
+  GroupInfo,
+  GroupInput,
+  GroupName,
+  GroupNameToGroupInfoMap,
+  GroupOptionsInput,
+  Hashtag,
+  HashtagsInput,
+  ImagesForDiff,
+  IncludedInInfo,
   MergeableInfo,
-  CommitInfo,
-} from '../../../types/common';
-import {ParsedChangeInfo} from '../../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
-import {HttpMethod, IgnoreWhitespaceType} from '../../../constants/constants';
+  NameToProjectInfoMap,
+  NumericChangeId,
+  ParsedJSON,
+  Password,
+  PatchRange,
+  PatchSetNum,
+  PathToCommentsInfoMap,
+  PathToRobotCommentsInfoMap,
+  PluginInfo,
+  PreferencesInfo,
+  PreferencesInput,
+  ProjectAccessInfo,
+  ProjectAccessInfoMap,
+  ProjectAccessInput,
+  ProjectInfo,
+  ProjectInfoWithName,
+  ProjectInput,
+  ProjectWatchInfo,
+  RelatedChangesInfo,
+  RepoName,
+  RequestPayload,
+  ReviewInput,
+  RevisionId,
+  RobotCommentInfo,
+  ServerInfo,
+  SshKeyInfo,
+  SubmittedTogetherInfo,
+  SuggestedReviewerInfo,
+  TagInfo,
+  TagInput,
+  TopMenuEntryInfo,
+  UrlEncodedCommentId,
+} from '../../types/common';
+import {
+  DiffInfo,
+  DiffPreferencesInfo,
+  IgnoreWhitespaceType,
+} from '../../types/diff';
+import {ParsedChangeInfo} from '../../types/types';
+import {ErrorCallback} from '../../api/rest';
 
-export type ErrorCallback = (response?: Response | null, err?: Error) => void;
 export type CancelConditionCallback = () => boolean;
 
 // TODO(TS): remove when GrReplyDialog converted to typescript
@@ -115,21 +118,6 @@
   setPluginMessage(message: string): void;
 }
 
-// Copied from gr-change-actions.js
-export enum ActionType {
-  CHANGE = 'change',
-  REVISION = 'revision',
-}
-
-// Copied from gr-change-actions.js
-export enum ActionPriority {
-  CHANGE = 2,
-  DEFAULT = 0,
-  PRIMARY = 3,
-  REVIEW = -3,
-  REVISION = 1,
-}
-
 export interface GetDiffCommentsOutput {
   baseComments: CommentInfo[];
   comments: CommentInfo[];
@@ -141,9 +129,6 @@
 }
 
 export interface RestApiService {
-  // TODO(TS): unclear what is a second parameter. Looks like it is a mistake
-  // and it must be removed
-  dispatchEvent(event: Event, detail?: unknown): boolean;
   getConfig(noCache?: boolean): Promise<ServerInfo | undefined>;
   getLoggedIn(): Promise<boolean>;
   getPreferences(): Promise<PreferencesInfo | undefined>;
@@ -182,23 +167,19 @@
 
   getChangeSuggestedReviewers(
     changeNum: NumericChangeId,
-    input: string,
-    errFn?: ErrorCallback
+    input: string
   ): Promise<SuggestedReviewerInfo[] | undefined>;
   getChangeSuggestedCCs(
     changeNum: NumericChangeId,
-    input: string,
-    errFn?: ErrorCallback
+    input: string
   ): Promise<SuggestedReviewerInfo[] | undefined>;
   getSuggestedAccounts(
     input: string,
-    n?: number,
-    errFn?: ErrorCallback
+    n?: number
   ): Promise<AccountInfo[] | undefined>;
   getSuggestedGroups(
     input: string,
-    n?: number,
-    errFn?: ErrorCallback
+    n?: number
   ): Promise<GroupNameToGroupInfoMap | undefined>;
   executeChangeAction(
     changeNum: NumericChangeId,
@@ -232,30 +213,14 @@
   getDiffPreferences(): Promise<DiffPreferencesInfo | undefined>;
 
   saveDiffPreferences(prefs: DiffPreferenceInput): Promise<Response>;
-  saveDiffPreferences(
-    prefs: DiffPreferenceInput,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-  saveDiffPreferences(
-    prefs: DiffPreferenceInput,
-    errFn?: ErrorCallback
-  ): Promise<Response>;
 
   getEditPreferences(): Promise<EditPreferencesInfo | undefined>;
 
   saveEditPreferences(prefs: EditPreferencesInfo): Promise<Response>;
-  saveEditPreferences(
-    prefs: EditPreferencesInfo,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-  saveEditPreferences(
-    prefs: EditPreferencesInfo,
-    errFn?: ErrorCallback
-  ): Promise<Response>;
 
   getAccountEmails(): Promise<EmailInfo[] | undefined>;
   deleteAccountEmail(email: string): Promise<Response>;
-  setPreferredAccountEmail(email: string, errFn?: ErrorCallback): Promise<void>;
+  setPreferredAccountEmail(email: string): Promise<void>;
 
   getAccountSSHKeys(): Promise<SshKeyInfo[] | undefined>;
   deleteAccountSSHKey(key: string): void;
@@ -267,25 +232,11 @@
     revision: BranchInput
   ): Promise<Response>;
 
-  createRepoBranch(
-    name: RepoName,
-    branch: BranchName,
-    revision: BranchInput,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
   createRepoTag(
     name: RepoName,
     tag: string,
     revision: TagInput
   ): Promise<Response>;
-
-  createRepoTag(
-    name: RepoName,
-    tag: string,
-    revision: TagInput,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
   addAccountGPGKey(key: GpgKeysInput): Promise<Record<string, GpgKeyInfo>>;
   deleteAccountGPGKey(id: GpgKeyId): Promise<Response>;
   getAccountGPGKeys(): Promise<Record<string, GpgKeyInfo>>;
@@ -325,11 +276,6 @@
   ): Promise<ProjectAccessInfo | undefined>;
 
   createRepo(config: ProjectInput & {name: RepoName}): Promise<Response>;
-  createRepo(
-    config: ProjectInput & {name: RepoName},
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-  createRepo(config: ProjectInput, errFn?: ErrorCallback): Promise<Response>;
 
   getRepo(
     repo: RepoName,
@@ -452,11 +398,19 @@
   ): Promise<Response>;
 
   getDiffChangeDetail(
-    changeNum: NumericChangeId,
-    errFn?: ErrorCallback,
-    cancelCondition?: CancelConditionCallback
+    changeNum: NumericChangeId
   ): 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>;
@@ -512,11 +466,6 @@
     | Promise<PathToCommentsInfoMap | undefined>;
 
   createGroup(config: GroupInput & {name: string}): Promise<Response>;
-  createGroup(
-    config: GroupInput & {name: string},
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-  createGroup(config: GroupInput, errFn?: ErrorCallback): Promise<Response>;
 
   getPlugins(
     filter: string,
@@ -564,33 +513,21 @@
 
   generateAccountHttpPassword(): Promise<Password>;
 
-  setAccountName(name: string, errFn?: ErrorCallback): Promise<void>;
+  setAccountName(name: string): Promise<void>;
 
-  setAccountUsername(username: string, errFn?: ErrorCallback): Promise<void>;
+  setAccountUsername(username: string): Promise<void>;
 
   getWatchedProjects(): Promise<ProjectWatchInfo[] | undefined>;
 
   saveWatchedProjects(
-    projects: ProjectWatchInfo[],
-    errFn?: ErrorCallback
+    projects: ProjectWatchInfo[]
   ): Promise<ProjectWatchInfo[]>;
 
-  deleteWatchedProjects(
-    projects: ProjectWatchInfo[]
-  ): Promise<Response | undefined>;
-  deleteWatchedProjects(
-    projects: ProjectWatchInfo[],
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-  deleteWatchedProjects(
-    projects: ProjectWatchInfo[],
-    errFn?: ErrorCallback
-  ): Promise<Response | undefined>;
+  deleteWatchedProjects(projects: ProjectWatchInfo[]): Promise<Response>;
 
   getSuggestedProjects(
     inputVal: string,
-    n?: number,
-    errFn?: ErrorCallback
+    n?: number
   ): Promise<NameToProjectInfoMap | undefined>;
 
   invalidateGroupsCache(): void;
@@ -606,11 +543,8 @@
     user: AccountId | undefined | null,
     reason: string
   ): Promise<Response>;
-  setAccountDisplayName(
-    displayName: string,
-    errFn?: ErrorCallback
-  ): Promise<void>;
-  setAccountStatus(status: string, errFn?: ErrorCallback): Promise<void>;
+  setAccountDisplayName(displayName: string): Promise<void>;
+  setAccountStatus(status: string): Promise<void>;
   getAvatarChangeUrl(): Promise<string | undefined>;
   setDescription(
     changeNum: NumericChangeId,
@@ -650,10 +584,7 @@
     errFn?: ErrorCallback
   ): Promise<GroupAuditEventInfo[] | undefined>;
 
-  getGroupMembers(
-    groupName: GroupId | GroupName,
-    errFn?: ErrorCallback
-  ): Promise<AccountInfo[] | undefined>;
+  getGroupMembers(groupName: GroupId | GroupName): Promise<AccountInfo[]>;
 
   getIncludedGroup(
     groupName: GroupId | GroupName
@@ -680,10 +611,7 @@
     includedGroup: GroupId
   ): Promise<Response>;
 
-  runRepoGC(
-    repo: RepoName,
-    errFn?: ErrorCallback
-  ): Promise<Response | undefined>;
+  runRepoGC(repo: RepoName): Promise<Response>;
   getFileContent(
     changeNum: NumericChangeId,
     path: string,
@@ -780,11 +708,6 @@
 
   addAccountEmail(email: string): Promise<Response>;
 
-  addAccountEmail(
-    email: string,
-    errFn?: ErrorCallback
-  ): Promise<Response | undefined>;
-
   saveChangeReviewed(
     changeNum: NumericChangeId,
     reviewed: boolean
@@ -815,10 +738,7 @@
     hashtag: HashtagsInput
   ): Promise<Hashtag[]>;
 
-  setChangeTopic(
-    changeNum: NumericChangeId,
-    topic: string | null
-  ): Promise<string>;
+  setChangeTopic(changeNum: NumericChangeId, topic?: string): Promise<string>;
 
   getChangeFiles(
     changeNum: NumericChangeId,
@@ -842,15 +762,7 @@
     reviewed: boolean
   ): Promise<Response>;
 
-  saveFileReviewed(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
-    path: string,
-    reviewed: boolean,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  getTopMenus(errFn?: ErrorCallback): Promise<TopMenuEntryInfo[] | undefined>;
+  getTopMenus(): Promise<TopMenuEntryInfo[] | undefined>;
 
   setInProjectLookup(changeNum: NumericChangeId, project: RepoName): void;
   getMergeable(changeNum: NumericChangeId): Promise<MergeableInfo | undefined>;
diff --git a/polygerrit-ui/app/services/router/router-model.ts b/polygerrit-ui/app/services/router/router-model.ts
new file mode 100644
index 0000000..201cb3f
--- /dev/null
+++ b/polygerrit-ui/app/services/router/router-model.ts
@@ -0,0 +1,76 @@
+/**
+ * @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 {NumericChangeId, PatchSetNum} from '../../types/common';
+import {BehaviorSubject, Observable} from 'rxjs';
+import {distinctUntilChanged, map} from 'rxjs/operators';
+
+export enum GerritView {
+  ADMIN = 'admin',
+  AGREEMENTS = 'agreements',
+  CHANGE = 'change',
+  DASHBOARD = 'dashboard',
+  DIFF = 'diff',
+  DOCUMENTATION_SEARCH = 'documentation-search',
+  EDIT = 'edit',
+  GROUP = 'group',
+  PLUGIN_SCREEN = 'plugin-screen',
+  REPO = 'repo',
+  ROOT = 'root',
+  SEARCH = 'search',
+  SETTINGS = 'settings',
+}
+
+export interface RouterState {
+  view?: GerritView;
+  changeNum?: NumericChangeId;
+  patchNum?: PatchSetNum;
+}
+
+// TODO: Figure out how to best enforce immutability of all states. Use Immer?
+// Use DeepReadOnly?
+const initialState: RouterState = {};
+
+const privateState$ = new BehaviorSubject<RouterState>(initialState);
+
+// Re-exporting as Observable so that you can only subscribe, but not emit.
+export const routerState$: Observable<RouterState> = privateState$;
+
+// Must only be used by the router service or whatever is in control of this
+// model.
+export function updateState(
+  view?: GerritView,
+  changeNum?: NumericChangeId,
+  patchNum?: PatchSetNum
+) {
+  privateState$.next({
+    ...privateState$.getValue(),
+    view,
+    changeNum,
+    patchNum,
+  });
+}
+
+export const routerChangeNum$ = routerState$.pipe(
+  map(state => state.changeNum),
+  distinctUntilChanged()
+);
+
+export const routerPatchNum$ = routerState$.pipe(
+  map(state => state.patchNum),
+  distinctUntilChanged()
+);
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.ts b/polygerrit-ui/app/styles/gr-change-list-styles.ts
index 6de4e6f..d1fcdc9 100644
--- a/polygerrit-ui/app/styles/gr-change-list-styles.ts
+++ b/polygerrit-ui/app/styles/gr-change-list-styles.ts
@@ -82,7 +82,7 @@
         vertical-align: middle;
       }
       .owner {
-        --account-max-length: 120px;
+        --account-max-length: 100px;
       }
       .branch,
       .star,
diff --git a/polygerrit-ui/app/styles/gr-menu-page-styles.ts b/polygerrit-ui/app/styles/gr-menu-page-styles.ts
index 8e8b264..e9a79c1f 100644
--- a/polygerrit-ui/app/styles/gr-menu-page-styles.ts
+++ b/polygerrit-ui/app/styles/gr-menu-page-styles.ts
@@ -28,7 +28,7 @@
       :host {
         display: block;
       }
-      main {
+      .main {
         margin: var(--spacing-xxl) auto;
         max-width: 50em;
       }
@@ -36,7 +36,7 @@
         margin-left: 14em;
         padding: var(--spacing-l) 0 var(--spacing-l) var(--spacing-xxl);
       }
-      main.table,
+      .main.table,
       .mainHeader {
         margin-top: 0;
         margin-right: 0;
@@ -52,10 +52,10 @@
         padding: var(--spacing-l);
       }
       @media only screen and (max-width: 67em) {
-        main {
+        .main {
           margin: var(--spacing-xxl) 0 var(--spacing-xxl) 15em;
         }
-        main.table {
+        .main.table {
           margin-left: 14em;
         }
       }
@@ -63,10 +63,10 @@
         .loading {
           padding: 0 var(--spacing-l);
         }
-        main {
+        .main {
           margin: var(--spacing-xxl) var(--spacing-l);
         }
-        main.table {
+        .main.table {
           margin: 0;
         }
         .mainHeader {
diff --git a/polygerrit-ui/app/styles/gr-subpage-styles.ts b/polygerrit-ui/app/styles/gr-subpage-styles.ts
index 640da66..41ee952 100644
--- a/polygerrit-ui/app/styles/gr-subpage-styles.ts
+++ b/polygerrit-ui/app/styles/gr-subpage-styles.ts
@@ -25,7 +25,7 @@
 $_documentContainer.innerHTML = `<dom-module id="gr-subpage-styles">
   <template>
     <style>
-      main {
+      .main {
         margin: var(--spacing-l);
       }
       .loading {
diff --git a/polygerrit-ui/app/styles/gr-voting-styles.ts b/polygerrit-ui/app/styles/gr-voting-styles.ts
index d4e6d52..c1989de 100644
--- a/polygerrit-ui/app/styles/gr-voting-styles.ts
+++ b/polygerrit-ui/app/styles/gr-voting-styles.ts
@@ -27,7 +27,7 @@
     <style>
       :host {
         --vote-chip-styles: {
-          border: 1px solid rgba(0,0,0,.12);
+          border: 1px solid var(--border-color);
           border-radius: 1em;
           box-shadow: none;
           box-sizing: border-box;
diff --git a/polygerrit-ui/app/styles/shared-styles.ts b/polygerrit-ui/app/styles/shared-styles.ts
index 695ae24..6d128b3 100644
--- a/polygerrit-ui/app/styles/shared-styles.ts
+++ b/polygerrit-ui/app/styles/shared-styles.ts
@@ -15,6 +15,8 @@
  * limitations under the License.
  */
 
+import {css} from 'lit-element';
+
 // Mark the file as a module. Otherwise typescript assumes this is a script
 // and $_documentContainer is a global variable.
 // See: https://www.typescriptlang.org/docs/handbook/modules.html
@@ -22,189 +24,277 @@
 
 const $_documentContainer = document.createElement('template');
 
+export const sharedStyles = css`
+  /* CSS reset */
+
+  html,
+  body,
+  button,
+  div,
+  span,
+  applet,
+  object,
+  iframe,
+  h1,
+  h2,
+  h3,
+  h4,
+  h5,
+  h6,
+  p,
+  blockquote,
+  pre,
+  a,
+  abbr,
+  acronym,
+  address,
+  big,
+  cite,
+  code,
+  del,
+  dfn,
+  em,
+  img,
+  ins,
+  kbd,
+  q,
+  s,
+  samp,
+  small,
+  strike,
+  strong,
+  sub,
+  sup,
+  tt,
+  var,
+  b,
+  u,
+  i,
+  center,
+  dl,
+  dt,
+  dd,
+  ol,
+  ul,
+  li,
+  fieldset,
+  form,
+  label,
+  legend,
+  table,
+  caption,
+  tbody,
+  tfoot,
+  thead,
+  tr,
+  th,
+  td,
+  article,
+  aside,
+  canvas,
+  details,
+  embed,
+  figure,
+  figcaption,
+  footer,
+  header,
+  hgroup,
+  main,
+  menu,
+  nav,
+  output,
+  ruby,
+  section,
+  summary,
+  time,
+  mark,
+  audio,
+  video {
+    border: 0;
+    box-sizing: border-box;
+    font-size: 100%;
+    font: inherit;
+    margin: 0;
+    padding: 0;
+    vertical-align: baseline;
+  }
+  *::after,
+  *::before {
+    box-sizing: border-box;
+  }
+  input {
+    background-color: var(--background-color-primary);
+    border: 1px solid var(--border-color);
+    border-radius: var(--border-radius);
+    box-sizing: border-box;
+    color: var(--primary-text-color);
+    margin: 0;
+    padding: var(--spacing-s);
+  }
+  iron-autogrow-textarea {
+    background-color: inherit;
+    color: var(--primary-text-color);
+    border: 1px solid var(--border-color);
+    border-radius: var(--border-radius);
+    padding: 0;
+    box-sizing: border-box;
+    /* iron-autogrow-textarea has a "-webkit-appearance: textarea" :host
+        css rule, which prevents overriding the border color. Clear that. */
+    -webkit-appearance: none;
+
+    --iron-autogrow-textarea: {
+      box-sizing: border-box;
+      padding: var(--spacing-s);
+    }
+  }
+  a {
+    color: var(--link-color);
+  }
+  input,
+  textarea,
+  select,
+  button {
+    font: inherit;
+  }
+  ol,
+  ul {
+    list-style: none;
+  }
+  blockquote,
+  q {
+    quotes: none;
+  }
+  blockquote:before,
+  blockquote:after,
+  q:before,
+  q:after {
+    content: '';
+    content: none;
+  }
+  table {
+    border-collapse: collapse;
+    border-spacing: 0;
+  }
+
+  /* Fonts */
+
+  .font-normal {
+    font-size: var(--font-size-normal);
+    font-weight: var(--font-weight-normal);
+    line-height: var(--line-height-normal);
+  }
+  .font-small {
+    font-size: var(--font-size-small);
+    font-weight: var(--font-weight-normal);
+    line-height: var(--line-height-small);
+  }
+  .heading-1 {
+    font-family: var(--header-font-family);
+    font-size: var(--font-size-h1);
+    font-weight: var(--font-weight-h1);
+    line-height: var(--line-height-h1);
+  }
+  .heading-2 {
+    font-family: var(--header-font-family);
+    font-size: var(--font-size-h2);
+    font-weight: var(--font-weight-h2);
+    line-height: var(--line-height-h2);
+  }
+  .heading-3 {
+    font-family: var(--header-font-family);
+    font-size: var(--font-size-h3);
+    font-weight: var(--font-weight-h3);
+    line-height: var(--line-height-h3);
+  }
+  iron-icon {
+    color: var(--deemphasized-text-color);
+    vertical-align: top;
+    --iron-icon-height: 20px;
+    --iron-icon-width: 20px;
+  }
+
+  /* Stopgap solution until we remove hidden$ attributes. */
+
+  :host([hidden]),
+  [hidden] {
+    display: none !important;
+  }
+  .separator {
+    border-left: 1px solid var(--border-color);
+    height: 20px;
+    margin: 0 8px;
+  }
+  .separator.transparent {
+    border-color: transparent;
+  }
+  paper-toggle-button {
+    --paper-toggle-button-checked-bar-color: var(--link-color);
+    --paper-toggle-button-checked-button-color: var(--link-color);
+  }
+  paper-tabs {
+    font-size: var(--font-size-h3);
+    font-weight: var(--font-weight-h3);
+    line-height: var(--line-height-h3);
+    --paper-font-common-base: {
+      font-family: var(--header-font-family);
+      -webkit-font-smoothing: initial;
+    }
+    --paper-tab-content-focused: {
+      /* paper-tabs uses 700 here, which can look awkward */
+      font-weight: var(--font-weight-h3);
+    }
+    --paper-tab-content-unselected: {
+      /* paper-tabs uses 0.8 here, but we want to control the color directly */
+      opacity: 1;
+      color: var(--deemphasized-text-color);
+    }
+  }
+  iron-autogrow-textarea {
+    /** This is needed for firefox */
+    --iron-autogrow-textarea_-_white-space: pre-wrap;
+  }
+  strong {
+    font-weight: var(--font-weight-bold);
+  }
+
+  .assistive-tech-only {
+    user-select: none;
+    clip: rect(1px, 1px, 1px, 1px);
+    height: 1px;
+    margin: 0;
+    overflow: hidden;
+    padding: 0;
+    position: absolute;
+    white-space: nowrap;
+    width: 1px;
+    z-index: -1000;
+  }
+
+  /** BEGIN: loading spiner */
+  .loadingSpin {
+    border: 2px solid var(--disabled-button-background-color);
+    border-top: 2px solid var(--primary-button-background-color);
+    border-radius: 50%;
+    width: 10px;
+    height: 10px;
+    animation: spin 2s linear infinite;
+    margin-right: var(--spacing-s);
+  }
+  @keyframes spin {
+    0% {
+      transform: rotate(0deg);
+    }
+    100% {
+      transform: rotate(360deg);
+    }
+  }
+  /** END: loading spiner */
+`;
+
 $_documentContainer.innerHTML = `<dom-module id="shared-styles">
   <template>
     <style>
-
-      /* CSS reset */
-
-      html, body, button, div, span, applet, object, iframe, h1, h2, h3,
-      h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite,
-      code, del, dfn, em, img, ins, kbd, q, s, samp, small, strike, strong, sub,
-      sup, tt, var, b, u, i, center, dl, dt, dd, ol, ul, li, fieldset, form,
-      label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article,
-      aside, canvas, details, embed, figure, figcaption, footer, header, hgroup,
-      main, menu, nav, output, ruby, section, summary, time, mark, audio, video {
-        border: 0;
-        box-sizing: border-box;
-        font-size: 100%;
-        font: inherit;
-        margin: 0;
-        padding: 0;
-        vertical-align: baseline;
-      }
-      *::after,
-      *::before {
-        box-sizing: border-box;
-      }
-      input {
-        background-color: var(--background-color-primary);
-        border: 1px solid var(--border-color);
-        border-radius: var(--border-radius);
-        box-sizing: border-box;
-        color: var(--primary-text-color);
-        margin: 0;
-        padding: var(--spacing-s);
-      }
-      iron-autogrow-textarea {
-        background-color: inherit;
-        color: var(--primary-text-color);
-        border: 1px solid var(--border-color);
-        border-radius: var(--border-radius);
-        padding: 0;
-        box-sizing: border-box;
-        /* iron-autogrow-textarea has a "-webkit-appearance: textarea" :host
-           css rule, which prevents overriding the border color. Clear that. */
-        -webkit-appearance: none;
-
-        --iron-autogrow-textarea: {
-          box-sizing: border-box;
-          padding: var(--spacing-s);
-        };
-      }
-      a {
-        color: var(--link-color);
-      }
-      input,
-      textarea,
-      select,
-      button {
-        font: inherit;
-      }
-      ol, ul {
-        list-style: none;
-      }
-      blockquote, q {
-        quotes: none;
-      }
-      blockquote:before, blockquote:after,
-      q:before, q:after {
-        content: '';
-        content: none;
-      }
-      table {
-        border-collapse: collapse;
-        border-spacing: 0;
-      }
-
-      /* Fonts */
-
-      .font-normal {
-        font-size: var(--font-size-normal);
-        font-weight: var(--font-weight-normal);
-        line-height: var(--line-height-normal);
-      }
-      .font-small {
-        font-size: var(--font-size-small);
-        font-weight: var(--font-weight-normal);
-        line-height: var(--line-height-small);
-      }
-      .heading-1 {
-        font-family: var(--header-font-family);
-        font-size: var(--font-size-h1);
-        font-weight: var(--font-weight-h1);
-        line-height: var(--line-height-h1);
-      }
-      .heading-2 {
-        font-family: var(--header-font-family);
-        font-size: var(--font-size-h2);
-        font-weight: var(--font-weight-h2);
-        line-height: var(--line-height-h2);
-      }
-      .heading-3 {
-        font-family: var(--header-font-family);
-        font-size: var(--font-size-h3);
-        font-weight: var(--font-weight-h3);
-        line-height: var(--line-height-h3);
-      }
-      iron-icon {
-        color: var(--deemphasized-text-color);
-        --iron-icon-height: 20px;
-        --iron-icon-width: 20px;
-      }
-
-      /* Stopgap solution until we remove hidden$ attributes. */
-
-      :host([hidden]),
-      [hidden] {
-        display: none !important;
-      }
-      .separator {
-        border-left: 1px solid var(--border-color);
-        height: 20px;
-        margin: 0 8px;
-      }
-      .separator.transparent {
-        border-color: transparent;
-      }
-      paper-toggle-button {
-        --paper-toggle-button-checked-bar-color: var(--link-color);
-        --paper-toggle-button-checked-button-color: var(--link-color);
-      }
-      paper-tabs {
-        font-size: var(--font-size-h3);
-        font-weight: var(--font-weight-h3);
-        line-height: var(--line-height-h3);
-        --paper-font-common-base: {
-          font-family: var(--header-font-family);
-          -webkit-font-smoothing: initial;
-        };
-        --paper-tab-content-focused: {
-          /* paper-tabs uses 700 here, which can look awkward */
-          font-weight: var(--font-weight-h3);
-        };
-        --paper-tab-content-unselected: {
-          /* paper-tabs uses 0.8 here, but we want to control the color directly */
-          opacity: 1;
-          color: var(--deemphasized-text-color);
-        };
-      }
-      iron-autogrow-textarea {
-        /** This is needed for firefox */
-        --iron-autogrow-textarea_-_white-space: pre-wrap;
-      }
-      strong {
-        font-weight: var(--font-weight-bold);
-      }
-
-      .assistive-tech-only {
-        user-select: none;
-        clip: rect(1px, 1px, 1px, 1px);
-        height: 1px;
-        margin: 0;
-        overflow: hidden;
-        padding: 0;
-        position: absolute;
-        white-space: nowrap;
-        width: 1px;
-        z-index: -1000;
-      }
-
-      /** BEGIN: loading spiner */
-      .loadingSpin {
-        border: 2px solid var(--disabled-button-background-color);
-        border-top: 2px solid var(--primary-button-background-color);
-        border-radius: 50%;
-        width: 10px;
-        height: 10px;
-        animation: spin 2s linear infinite;
-        margin-right: var(--spacing-s);
-      }
-      @keyframes spin {
-        0% { transform: rotate(0deg); }
-        100% { transform: rotate(360deg); }
-      }
-      /** END: loading spiner */
+    ${sharedStyles.cssText}
     </style>
   </template>
 </dom-module>`;
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index 9586c09..18c12b0 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -39,12 +39,56 @@
      * can be a breaking change that should go into the release notes.
      */
 
+    /* color palette */
+    --red-900: #a50e0e;
+    --red-700: #c5221f;
+    --red-200: #f6aea9;
+    --red-50: #fce8e6;
+    --blue-900: #174ea6;
+    --blue-700: #1967d2;
+    --blue-200: #aecbfa;
+    --blue-50: #e8f0fe;
+    --orange-900: #b06000;
+    --orange-700: #d56e0c;
+    --orange-200: #fdc69c;
+    --orange-50: #feefe3;
+    --cyan-900: #007b83;
+    --cyan-700: #129eaf;
+    --cyan-100: #cbf0f8;
+    --cyan-50: #e4f7fb;
+    --green-900: #0d652d;
+    --green-700: #188038;
+    --green-200: #a8dab5;
+    --green-50: #e6f4ea;
+    --gray-900: #202124;
+    --gray-700: #5f6368;
+    --gray-300: #dadce0;
+    --gray-100: #f1f3f4;
+    --gray-50: #f8f9fa;
+
+    --chip-color: var(--gray-900);
+    --error-color: var(--red-900);
+    --error-foreground: var(--red-700);
+    --error-background: var(--red-50);
+    --warning-foreground: var(--orange-700);
+    --warning-background: var(--orange-50);
+    --info-foreground: var(--blue-700);
+    --info-background: var(--blue-50);
+    --selected-foreground: var(--blue-700);
+    --selected-background: var(--blue-50);
+    --success-foreground: var(--green-700);
+    --success-background: var(--green-50);
+    --gray-foreground: var(--gray-700);
+    --gray-background: var(--gray-100);
+    --tag-background: var(--cyan-100);
+    --label-background: var(--red-50);
+
     /* text colors */
     --primary-text-color: black;
-    --link-color: #2a66d9;
+    --link-color: var(--blue-700);
     --comment-text-color: black;
-    --deemphasized-text-color: #5F6368;
-    --default-button-text-color: #2a66d9;
+    --deemphasized-text-color: var(--gray-700);
+    --default-button-text-color: var(--blue-700);
     --chip-selected-text-color: var(--default-button-text-color);
     --error-text-color: red;
     --primary-button-text-color: white;
@@ -55,6 +99,7 @@
     --tooltip-text-color: white;
     --negative-red-text-color: #d93025;
     --positive-green-text-color: #188038;
+    --indirect-ancestor-text-color: var(--green-700);
 
     /* background colors */
     /* primary background colors */
@@ -78,17 +123,17 @@
     /* TODO: Find a nicer way to combine the --assignee-highlight-color and the
        --selection-background-color than to just invent another unique color. */
     --assignee-highlight-selection-color: #f6f4d0;
-    --chip-selected-background-color: #e8f0fe;
+    --chip-selected-background-color: var(--blue-50);
     --edit-mode-background-color: #ebf5fb;
     --emphasis-color: #fff9c4;
     --hover-background-color: rgba(161, 194, 250, 0.2);
     --disabled-button-background-color: #e8eaed;
-    --primary-button-background-color: #2a66d9;
+    --primary-button-background-color: var(--blue-700);
     --selection-background-color: rgba(161, 194, 250, 0.1);
     --tooltip-background-color: #333;
     /* comment background colors */
     --comment-background-color: #e8eaed;
-    --robot-comment-background-color: #e8f0fe;
+    --robot-comment-background-color: var(--blue-50);
     --unresolved-comment-background-color: #fef7e0;
     /* vote background colors */
     --vote-color-approved: #9fcc6b;
@@ -98,12 +143,12 @@
     --vote-color-rejected: #f7a1ad;
 
     /* misc colors */
-    --border-color: #e8e8e8;
-    --comment-separator-color: #dadce0;
+    --border-color: var(--gray-300);
+    --comment-separator-color: var(--gray-300);
 
     /* status colors */
     --status-merged: #188038;
-    --status-abandoned: #5f6368;
+    --status-abandoned: var(--gray-700);
     --status-wip: #795548;
     --status-private: #a142f4;
     --status-conflict: #d93025;
@@ -122,11 +167,10 @@
     --font-size-h3: 1.143rem;   /* 16px */
     --font-size-h2: 1.429rem;   /* 20px */
     --font-size-h1: 1.714rem;   /* 24px */
-    --line-height-code: 1.143rem;   /* 16px */
     --line-height-mono: 1.286rem;   /* 18px */
     --line-height-small: 1.143rem;  /* 16px */
     --line-height-normal: 1.429rem; /* 20px */
-    --line-height-h3: 1.714rem;     /* 24px */
+    --line-height-h3: 1.715rem;     /* 24px */
     --line-height-h2: 2rem;         /* 28px */
     --line-height-h1: 2.286rem;     /* 32px */
     --font-weight-normal: 400; /* 400 is the same as 'normal' */
@@ -167,7 +211,7 @@
     --diff-blank-background-color: var(--background-color-secondary);
     --diff-context-control-background-color: #fff7d4;
     --diff-context-control-border-color: #f6e6a5;
-    --diff-context-control-color: var(--deemphasized-text-color);
+    --diff-context-control-color: var(--default-button-text-color);
     --diff-highlight-range-color: rgba(255, 213, 0, 0.5);
     --diff-highlight-range-hover-color: rgba(255, 255, 0, 0.5);
     --diff-selection-background-color: #c7dbf9;
@@ -175,11 +219,16 @@
     --diff-trailing-whitespace-indicator: #ff9ad2;
     --light-add-highlight-color: #d8fed8;
     --light-rebased-add-highlight-color: #eef;
-    --light-moved-add-highlight-color: #eef;
+    --diff-moved-in-background: #e4f7fb;
+    --diff-moved-out-background: #f3e8fd;
+    --diff-moved-in-label-background: #007b83;
+    --diff-moved-out-label-background: #681da8;
     --light-remove-add-highlight-color: #fff8dc;
     --light-remove-highlight-color: #ffebee;
     --coverage-covered: #e0f2f1;
     --coverage-not-covered: #ffd1a4;
+    --ranged-comment-chip-background: #b06000;
+    --ranged-comment-chip-text-color: #feefe3;
 
     /* syntax colors */
     --syntax-attr-color: #219;
@@ -206,7 +255,7 @@
     --syntax-template-tag-color: #fa8602;
     --syntax-template-variable-color: #0000c0;
     --syntax-title-color: #0000c0;
-    --syntax-type-color: #2a66d9;
+    --syntax-type-color: var(--blue-700);
     --syntax-variable-color: var(--primary-text-color);
 
     /* elevation */
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
index 4d3e6d8..5455a24 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.ts
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -36,6 +36,23 @@
        * you probably want to override all.
        */
 
+      --chip-color: var(--gray-100);
+      --error-color: var(--red-200);
+      --error-foreground: var(--red-200);
+      --error-background: var(--red-900);
+      --warning-foreground: var(--orange-200);
+      --warning-background: var(--orange-900);
+      --info-foreground: var(--blue-200);
+      --info-background: var(--blue-900);
+      --selected-foreground: var(--blue-200);
+      --selected-background: var(--blue-900);
+      --success-foreground: var(--green-200);
+      --success-background: var(--green-900);
+      --gray-foreground: var(--gray-100);
+      --gray-background: var(--gray-900);
+      --tag-background: var(--cyan-900);
+      --label-background: var(--red-900);
+
       /* text colors */
       --primary-text-color: #e8eaed;
       --link-color: #8ab4f8;
@@ -83,7 +100,7 @@
       --vote-color-rejected: #ac2d3e;
 
       /* misc colors */
-      --border-color: #5f6368;
+      --border-color: var(--gray-700);
       --comment-separator-color: var(--border-color);
 
       /* status colors */
@@ -125,11 +142,16 @@
       --diff-trailing-whitespace-indicator: #ff9ad2;
       --light-add-highlight-color: #0f401f;
       --light-rebased-add-highlight-color: #487165;
-      --light-moved-add-highlight-color: #487165;
+      --diff-moved-in-background: #006066;
+      --diff-moved-out-background: #681da8;
+      --diff-moved-in-label-background: #cbf0f8;
+      --diff-moved-out-label-background: #e9d2fd;
       --light-remove-add-highlight-color: #2f3f2f;
       --light-remove-highlight-color: #320404;
       --coverage-covered: #112826;
       --coverage-not-covered: #6b3600;
+      --ranged-comment-chip-background: #e8f0fe;
+      --ranged-comment-chip-text-color: #174ea6;
 
       /* syntax colors */
       --syntax-attr-color: #80cbbf;
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index 71c45f7..4e1662c 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -23,12 +23,13 @@
 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,
+  addIronOverlayBackdropStyleEl,
+  removeIronOverlayBackdropStyleEl,
   TestKeyboardShortcutBinder,
 } from './test-utils';
 import {flushDebouncers} from '@polymer/polymer/lib/utils/debounce';
@@ -42,7 +43,6 @@
   _testOnly_defaultResinReportHandler,
   installPolymerResin,
 } from '../scripts/polymer-resin-install';
-import {hasOwnProperty} from '../utils/common-util';
 
 declare global {
   interface Window {
@@ -90,10 +90,13 @@
 }
 
 window.fixture = fixtureImpl;
+let testSetupTimestampMs = 0;
 
 setup(() => {
+  testSetupTimestampMs = new Date().getTime();
   window.Gerrit = {};
   initGlobalVariables();
+  addIronOverlayBackdropStyleEl();
 
   // If the following asserts fails - then window.stub is
   // overwritten by some other code.
@@ -119,7 +122,6 @@
   // to reset this behavior if you need to test something specific.
   pl.loadPlugins([]);
   _testOnlyResetGrRestApiSharedObjects();
-  _testOnlyResetRestApi();
 });
 
 // For karma always set our implementation
@@ -131,12 +133,9 @@
   // 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]));
-    }
+  for (const [key, value] of Object.entries(implementation)) {
+    stubs.push(sinon.stub(proto, key).callsFake(value));
   }
   registerTestCleanup(() => {
     stubs.forEach(stub => {
@@ -196,6 +195,17 @@
   cleanupTestUtils();
   TestKeyboardShortcutBinder.pop();
   checkGlobalSpace();
+  removeIronOverlayBackdropStyleEl();
   // Clean Polymer debouncer queue, so next tests will not be affected.
+  // WARNING! This will most likely not do what you expect. `flushDebouncers()`
+  // will only flush debouncers that were added using `enqueueDebouncer()`. So
+  // this will not affect "normal" debouncers that were added using
+  // `this.debounce()`. For those please be careful and cancel them using
+  // `this.cancelDebouncer()` in the `detached()` lifecycle hook.
   flushDebouncers();
+  const testTeardownTimestampMs = new Date().getTime();
+  const elapsedMs = testTeardownTimestampMs - testSetupTimestampMs;
+  if (elapsedMs > 1000) {
+    console.warn(`ATTENTION! Test took longer than 1 second: ${elapsedMs} ms`);
+  }
 });
diff --git a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
new file mode 100644
index 0000000..5efc562
--- /dev/null
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -0,0 +1,531 @@
+/* eslint-disable @typescript-eslint/no-unused-vars */
+/**
+ * @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 {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
+import {
+  AccountDetailInfo,
+  AccountExternalIdInfo,
+  AccountInfo,
+  ServerInfo,
+  ProjectInfo,
+  AccountCapabilityInfo,
+  SuggestedReviewerInfo,
+  GroupNameToGroupInfoMap,
+  ParsedJSON,
+  EditPreferencesInfo,
+  SshKeyInfo,
+  RepoName,
+  GpgKeyInfo,
+  PreferencesInfo,
+  EmailInfo,
+  ProjectAccessInfo,
+  CapabilityInfoMap,
+  ChangeInfo,
+  ProjectInfoWithName,
+  GroupInfo,
+  BranchInfo,
+  ConfigInfo,
+  EditInfo,
+  DashboardInfo,
+  ProjectAccessInfoMap,
+  IncludedInInfo,
+  CommentInfo,
+  PathToCommentsInfoMap,
+  PluginInfo,
+  DocResult,
+  ContributorAgreementInfo,
+  Password,
+  ProjectWatchInfo,
+  NameToProjectInfoMap,
+  GroupAuditEventInfo,
+  Base64FileContent,
+  TagInfo,
+  RelatedChangesInfo,
+  SubmittedTogetherInfo,
+  FilePathToDiffInfoMap,
+  BlameInfo,
+  ImagesForDiff,
+  ActionNameToActionInfoMap,
+  Hashtag,
+  FileNameToFileInfoMap,
+  TopMenuEntryInfo,
+  MergeableInfo,
+  CommitInfo,
+  GroupId,
+  GroupName,
+  UrlEncodedRepoName,
+} from '../../types/common';
+import {DiffInfo, DiffPreferencesInfo} from '../../types/diff';
+import {readResponsePayload} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {
+  createAccountDetailWithId,
+  createChange,
+  createCommit,
+  createConfig,
+  createPreferences,
+  createServerInfo,
+} from '../test-data-generators';
+import {
+  createDefaultDiffPrefs,
+  createDefaultEditPrefs,
+} from '../../constants/constants';
+import {ParsedChangeInfo} from '../../types/types';
+
+export const grRestApiMock: RestApiService = {
+  addAccountEmail(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  addAccountGPGKey(): Promise<Record<string, GpgKeyInfo>> {
+    return Promise.resolve({});
+  },
+  addAccountSSHKey(): Promise<SshKeyInfo> {
+    throw new Error('addAccountSSHKey() not implemented by RestApiMock.');
+  },
+  addToAttentionSet(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  applyFixSuggestion(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  awaitPendingDiffDrafts(): Promise<void> {
+    return Promise.resolve();
+  },
+  confirmEmail(): Promise<string | null> {
+    return Promise.resolve('');
+  },
+  createChange(): Promise<ChangeInfo | undefined> {
+    throw new Error('createChange() not implemented by RestApiMock.');
+  },
+  createGroup(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  createRepo(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  createRepoBranch(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  createRepoTag(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  deleteAccountEmail(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  deleteAccountGPGKey(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  deleteAccountIdentity(): Promise<unknown> {
+    return Promise.resolve(new Response());
+  },
+  deleteAccountSSHKey(): void {},
+  deleteAssignee(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  deleteChangeCommitMessage(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  deleteComment(): Promise<CommentInfo> {
+    throw new Error('deleteComment() not implemented by RestApiMock.');
+  },
+  deleteDiffDraft(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  deleteDraftComments(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  deleteFileInChangeEdit(): Promise<Response | undefined> {
+    return Promise.resolve(new Response());
+  },
+  deleteGroupMember(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  deleteIncludedGroup(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  deleteRepoBranches(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  deleteRepoTags(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  deleteVote(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  deleteWatchedProjects(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  executeChangeAction(): Promise<Response | undefined> {
+    return Promise.resolve(new Response());
+  },
+  generateAccountHttpPassword(): Promise<Password> {
+    return Promise.resolve('asdf');
+  },
+  getAccount(): Promise<AccountDetailInfo | undefined> {
+    return Promise.resolve(createAccountDetailWithId(1));
+  },
+  getAccountAgreements(): Promise<ContributorAgreementInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getAccountCapabilities(): Promise<AccountCapabilityInfo | undefined> {
+    return Promise.resolve({});
+  },
+  getAccountDetails(): Promise<AccountDetailInfo | undefined> {
+    return Promise.resolve(createAccountDetailWithId(1));
+  },
+  getAccountEmails(): Promise<EmailInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getAccountGPGKeys(): Promise<Record<string, GpgKeyInfo>> {
+    return Promise.resolve({});
+  },
+  getAccountGroups(): Promise<GroupInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getAccountSSHKeys(): Promise<SshKeyInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getAccountStatus(): Promise<string | undefined> {
+    return Promise.resolve('');
+  },
+  getAvatarChangeUrl(): Promise<string | undefined> {
+    return Promise.resolve('');
+  },
+  getBlame(): Promise<BlameInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getCapabilities(): Promise<CapabilityInfoMap | undefined> {
+    return Promise.resolve({});
+  },
+  getChange(): Promise<ChangeInfo | null> {
+    throw new Error('getChange() not implemented by RestApiMock.');
+  },
+  getChangeActionURL(): Promise<string> {
+    return Promise.resolve('');
+  },
+  getChangeCherryPicks(): Promise<ChangeInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getChangeCommitInfo(): Promise<CommitInfo | undefined> {
+    return Promise.resolve(createCommit());
+  },
+  getChangeConflicts(): Promise<ChangeInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getChangeDetail(): Promise<ParsedChangeInfo | null | undefined> {
+    return Promise.resolve(createChange() as ParsedChangeInfo);
+  },
+  getChangeEdit(): Promise<false | EditInfo | undefined> {
+    return Promise.resolve(false);
+  },
+  getChangeFiles(): Promise<FileNameToFileInfoMap | undefined> {
+    return Promise.resolve({});
+  },
+  getChangeIncludedIn(): Promise<IncludedInInfo | undefined> {
+    throw new Error('getChangeIncludedIn() not implemented by RestApiMock.');
+  },
+  getChangeOrEditFiles(): Promise<FileNameToFileInfoMap | undefined> {
+    return Promise.resolve({});
+  },
+  getChangeRevisionActions(): Promise<ActionNameToActionInfoMap | undefined> {
+    return Promise.resolve({});
+  },
+  getChangeSuggestedCCs(): Promise<SuggestedReviewerInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getChangeSuggestedReviewers(): Promise<SuggestedReviewerInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getChanges() {
+    return Promise.resolve([]);
+  },
+  getChangesSubmittedTogether(): Promise<SubmittedTogetherInfo | undefined> {
+    throw new Error('getChangesSubmittedTogether() not implemented by mock.');
+  },
+  getChangesWithSameTopic(): Promise<ChangeInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getConfig(): Promise<ServerInfo | undefined> {
+    return Promise.resolve(createServerInfo());
+  },
+  getDashboard(): Promise<DashboardInfo | undefined> {
+    throw new Error('getDashboard() not implemented by RestApiMock.');
+  },
+  getDefaultPreferences(): Promise<PreferencesInfo | undefined> {
+    throw new Error('getDefaultPreferences() not implemented by RestApiMock.');
+  },
+  getDiff(): Promise<DiffInfo | undefined> {
+    throw new Error('getDiff() not implemented by RestApiMock.');
+  },
+  getDiffChangeDetail(): Promise<ChangeInfo | undefined | null> {
+    throw new Error('getDiffChangeDetail() not implemented by RestApiMock.');
+  },
+  getDiffComments() {
+    throw new Error('getDiffComments() not implemented by RestApiMock.');
+  },
+  getDiffDrafts() {
+    throw new Error('getDiffDrafts() not implemented by RestApiMock.');
+  },
+  getDiffPreferences(): Promise<DiffPreferencesInfo | undefined> {
+    return Promise.resolve(createDefaultDiffPrefs());
+  },
+  getDiffRobotComments() {
+    throw new Error('getDiffRobotComments() not implemented by RestApiMock.');
+  },
+  getDocumentationSearches(): Promise<DocResult[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getEditPreferences(): Promise<EditPreferencesInfo | undefined> {
+    return Promise.resolve(createDefaultEditPrefs());
+  },
+  getExternalIds(): Promise<AccountExternalIdInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getFileContent(): Promise<Response | Base64FileContent | undefined> {
+    return Promise.resolve(new Response());
+  },
+  getFromProjectLookup(): Promise<RepoName | undefined> {
+    throw new Error('getFromProjectLookup() not implemented by RestApiMock.');
+  },
+  getGroupAuditLog(): Promise<GroupAuditEventInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getGroupConfig(id: GroupId | GroupName): Promise<GroupInfo | undefined> {
+    return Promise.resolve({
+      id: id as GroupId,
+    });
+  },
+  getGroupMembers(): Promise<AccountInfo[]> {
+    return Promise.resolve([]);
+  },
+  getGroups(): Promise<GroupNameToGroupInfoMap | undefined> {
+    return Promise.resolve({});
+  },
+  getImagesForDiff(): Promise<ImagesForDiff> {
+    throw new Error('getImagesForDiff() not implemented by RestApiMock.');
+  },
+  getIncludedGroup(): Promise<GroupInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getIsAdmin(): Promise<boolean | undefined> {
+    return Promise.resolve(false);
+  },
+  getIsGroupOwner(): Promise<boolean> {
+    return Promise.resolve(false);
+  },
+  getLoggedIn(): Promise<boolean> {
+    return Promise.resolve(true);
+  },
+  getMergeable(): Promise<MergeableInfo | undefined> {
+    throw new Error('getMergeable() not implemented by RestApiMock.');
+  },
+  getPlugins(): Promise<{[p: string]: PluginInfo} | undefined> {
+    return Promise.resolve({});
+  },
+  getPortedComments(): Promise<PathToCommentsInfoMap | undefined> {
+    return Promise.resolve({});
+  },
+  getPortedDrafts(): Promise<PathToCommentsInfoMap | undefined> {
+    return Promise.resolve({});
+  },
+  getPreferences(): Promise<PreferencesInfo | undefined> {
+    return Promise.resolve(createPreferences());
+  },
+  getProjectConfig(): Promise<ConfigInfo | undefined> {
+    return Promise.resolve(createConfig());
+  },
+  getRelatedChanges(): Promise<RelatedChangesInfo | undefined> {
+    return Promise.resolve({changes: []});
+  },
+  getRepo(repo: RepoName): Promise<ProjectInfo | undefined> {
+    return Promise.resolve({
+      id: (repo as string) as UrlEncodedRepoName,
+      name: repo,
+    });
+  },
+  getRepoAccess(): Promise<ProjectAccessInfoMap | undefined> {
+    return Promise.resolve({});
+  },
+  getRepoAccessRights(): Promise<ProjectAccessInfo | undefined> {
+    return Promise.resolve(undefined);
+  },
+  getRepoBranches(): Promise<BranchInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getRepoDashboards(): Promise<DashboardInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getRepoTags(): Promise<TagInfo[]> {
+    return Promise.resolve([]);
+  },
+  getRepos(): Promise<ProjectInfoWithName[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getResponseObject(response: Response): Promise<ParsedJSON> {
+    return readResponsePayload(response).then(payload => payload.parsed);
+  },
+  getReviewedFiles(): Promise<string[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getRobotCommentFixPreview(): Promise<FilePathToDiffInfoMap | undefined> {
+    return Promise.resolve({});
+  },
+  getSuggestedAccounts(): Promise<AccountInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getSuggestedGroups(): Promise<GroupNameToGroupInfoMap | undefined> {
+    return Promise.resolve({});
+  },
+  getSuggestedProjects(): Promise<NameToProjectInfoMap | undefined> {
+    return Promise.resolve({});
+  },
+  getTopMenus(): Promise<TopMenuEntryInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getVersion(): Promise<string | undefined> {
+    return Promise.resolve('');
+  },
+  getWatchedProjects(): Promise<ProjectWatchInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  hasPendingDiffDrafts(): number {
+    return 0;
+  },
+  invalidateAccountsCache(): void {},
+  invalidateGroupsCache(): void {},
+  invalidateReposCache(): void {},
+  probePath(): Promise<boolean> {
+    return Promise.resolve(true);
+  },
+  putChangeCommitMessage(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  queryChangeFiles(): Promise<string[] | undefined> {
+    return Promise.resolve([]);
+  },
+  removeChangeReviewer(): Promise<Response | undefined> {
+    return Promise.resolve(new Response());
+  },
+  removeFromAttentionSet(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  renameFileInChangeEdit(): Promise<Response | undefined> {
+    return Promise.resolve(new Response());
+  },
+  restoreFileInChangeEdit(): Promise<Response | undefined> {
+    return Promise.resolve(new Response());
+  },
+  runRepoGC(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  saveAccountAgreement(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  saveChangeEdit(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  saveChangeReview() {
+    return Promise.resolve(new Response());
+  },
+  saveChangeReviewed(): Promise<Response | undefined> {
+    return Promise.resolve(new Response());
+  },
+  saveChangeStarred(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  saveDiffDraft(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  saveDiffPreferences(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  saveEditPreferences(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  saveFileReviewed(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  saveFileUploadChangeEdit(): Promise<Response | undefined> {
+    return Promise.resolve(new Response());
+  },
+  saveGroupDescription(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  saveGroupMember(): Promise<AccountInfo> {
+    return Promise.resolve({});
+  },
+  saveGroupName(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  saveGroupOptions(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  saveGroupOwner(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  saveIncludedGroup(): Promise<GroupInfo | undefined> {
+    throw new Error('saveIncludedGroup() not implemented by RestApiMock.');
+  },
+  savePreferences(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  saveRepoConfig(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  saveWatchedProjects(): Promise<ProjectWatchInfo[]> {
+    return Promise.resolve([]);
+  },
+  send() {
+    return Promise.resolve(new Response());
+  },
+  setAccountDisplayName(): Promise<void> {
+    return Promise.resolve();
+  },
+  setAccountName(): Promise<void> {
+    return Promise.resolve();
+  },
+  setAccountStatus(): Promise<void> {
+    return Promise.resolve();
+  },
+  setAccountUsername(): Promise<void> {
+    return Promise.resolve();
+  },
+  setAssignee(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  setChangeHashtag(): Promise<Hashtag[]> {
+    return Promise.resolve([]);
+  },
+  setChangeTopic(): Promise<string> {
+    return Promise.resolve('');
+  },
+  setDescription(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  setInProjectLookup(): void {},
+  setPreferredAccountEmail(): Promise<void> {
+    return Promise.resolve();
+  },
+  setRepoAccessRights(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+  setRepoAccessRightsForReview(): Promise<ChangeInfo> {
+    throw new Error('setRepoAccessRightsForReview() not implemented by mock.');
+  },
+  setRepoHead(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
+};
diff --git a/polygerrit-ui/app/test/test-app-context-init.ts b/polygerrit-ui/app/test/test-app-context-init.ts
index 7f19903..57afd8a 100644
--- a/polygerrit-ui/app/test/test-app-context-init.ts
+++ b/polygerrit-ui/app/test/test-app-context-init.ts
@@ -19,6 +19,7 @@
 import {initAppContext} from '../services/app-context-init';
 import {grReportingMock} from '../services/gr-reporting/gr-reporting_mock';
 import {AppContext, appContext} from '../services/app-context';
+import {grRestApiMock} from './mocks/gr-rest-api_mock';
 
 export function _testOnlyInitAppContext() {
   initAppContext();
@@ -34,4 +35,5 @@
     });
   }
   setMock('reportingService', grReportingMock);
+  setMock('restApiService', grRestApiMock);
 }
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index 3b464a5..d2c1cd2 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -58,6 +58,9 @@
   TimezoneOffset,
   UserConfigInfo,
   AccountDetailInfo,
+  Requirement,
+  RequirementType,
+  UrlEncodedCommentId,
 } from '../types/common';
 import {
   AccountsVisibility,
@@ -74,15 +77,18 @@
   RevisionKind,
   SubmitType,
   TimeFormat,
+  RequirementStatus,
+  CommentSide,
 } from '../constants/constants';
 import {formatDate} from '../utils/date-util';
-import {GetDiffCommentsOutput} from '../services/services/gr-rest-api/gr-rest-api';
+import {GetDiffCommentsOutput} from '../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';
+import {CommitInfoWithRequiredCommit} from '../elements/change/gr-change-metadata/gr-change-metadata';
+import {WebLinkInfo} from '../types/diff';
+import {UIComment, UIDraft, createCommentThreads} from '../utils/comment-util';
+import {GerritView} from '../services/router/router-model';
+import {ChangeComments} from '../elements/diff/gr-comment-api/gr-comment-api';
+import {EditRevisionInfo, ParsedChangeInfo} from '../types/types';
 
 export function dateToTimestamp(date: Date): Timestamp {
   const nanosecondSuffix = '.000000000';
@@ -194,6 +200,13 @@
   };
 }
 
+export function createCommitInfoWithRequiredCommit(): CommitInfoWithRequiredCommit {
+  return {
+    ...createCommit(),
+    commit: 'commit' as CommitId,
+  };
+}
+
 export function createRevision(patchSetNum = 1): RevisionInfo {
   return {
     _number: patchSetNum as PatchSetNum,
@@ -388,6 +401,7 @@
   };
 }
 
+// TODO: Maybe reconcile with createDefaultPreferences() in constants.ts.
 export function createPreferences(): PreferencesInfo {
   return {
     changes_per_page: 10,
@@ -413,3 +427,151 @@
     project: TEST_PROJECT_NAME,
   };
 }
+
+export function createRequirement(): Requirement {
+  return {
+    status: RequirementStatus.OK,
+    fallbackText: '',
+    type: 'wip' as RequirementType,
+  };
+}
+
+export function createWebLinkInfo(): WebLinkInfo {
+  return {
+    name: 'gitiles',
+    url: '#',
+    image_url: 'gitiles.jpg',
+  };
+}
+
+export function createComment(): UIComment {
+  return {
+    patch_set: 1 as PatchSetNum,
+    id: '12345' as UrlEncodedCommentId,
+    side: CommentSide.REVISION,
+    line: 1,
+    message: 'hello world',
+    updated: '2018-02-13 22:48:48.018000000' as Timestamp,
+    unresolved: false,
+  };
+}
+
+export function createDraft(): UIDraft {
+  return {
+    ...createComment(),
+    collapsed: false,
+    __draft: true,
+    __editing: false,
+  };
+}
+
+export function createChangeComments(): ChangeComments {
+  const comments = {
+    '/COMMIT_MSG': [
+      {
+        ...createComment(),
+        message: 'Done',
+        updated: '2017-02-08 16:40:49' as Timestamp,
+        id: '1' as UrlEncodedCommentId,
+      },
+      {
+        ...createComment(),
+        message: 'oh hay',
+        updated: '2017-02-09 16:40:49' as Timestamp,
+        id: '2' as UrlEncodedCommentId,
+      },
+      {
+        ...createComment(),
+        patch_set: 2 as PatchSetNum,
+        message: 'hello',
+        updated: '2017-02-10 16:40:49' as Timestamp,
+        id: '3' as UrlEncodedCommentId,
+      },
+    ],
+    'myfile.txt': [
+      {
+        ...createComment(),
+        message: 'good news!',
+        updated: '2017-02-08 16:40:49' as Timestamp,
+        id: '4' as UrlEncodedCommentId,
+      },
+      {
+        ...createComment(),
+        patch_set: 2 as PatchSetNum,
+        message: 'wat!?',
+        updated: '2017-02-09 16:40:49' as Timestamp,
+        id: '5' as UrlEncodedCommentId,
+      },
+      {
+        ...createComment(),
+        patch_set: 2 as PatchSetNum,
+        message: 'hi',
+        updated: '2017-02-10 16:40:49' as Timestamp,
+        id: '6' as UrlEncodedCommentId,
+      },
+    ],
+    'unresolved.file': [
+      {
+        ...createComment(),
+        patch_set: 2 as PatchSetNum,
+        message: 'wat!?',
+        updated: '2017-02-09 16:40:49' as Timestamp,
+        id: '7' as UrlEncodedCommentId,
+        unresolved: true,
+      },
+      {
+        ...createComment(),
+        patch_set: 2 as PatchSetNum,
+        message: 'hi',
+        updated: '2017-02-10 16:40:49' as Timestamp,
+        id: '8' as UrlEncodedCommentId,
+        in_reply_to: '7' as UrlEncodedCommentId,
+        unresolved: false,
+      },
+      {
+        ...createComment(),
+        patch_set: 2 as PatchSetNum,
+        message: 'good news!',
+        updated: '2017-02-08 16:40:49' as Timestamp,
+        id: '9' as UrlEncodedCommentId,
+        unresolved: true,
+      },
+    ],
+  };
+  const drafts = {
+    '/COMMIT_MSG': [
+      {
+        ...createDraft(),
+        message: 'hi',
+        updated: '2017-02-15 16:40:49' as Timestamp,
+        id: '10' as UrlEncodedCommentId,
+        unresolved: true,
+      },
+      {
+        ...createDraft(),
+        message: 'fyi',
+        updated: '2017-02-15 16:40:49' as Timestamp,
+        id: '11' as UrlEncodedCommentId,
+        unresolved: false,
+      },
+    ],
+    'unresolved.file': [
+      {
+        ...createDraft(),
+        message: 'hi',
+        updated: '2017-02-11 16:40:49' as Timestamp,
+        id: '12' as UrlEncodedCommentId,
+        unresolved: false,
+      },
+    ],
+  };
+  return new ChangeComments(comments, {}, drafts, {}, {});
+}
+
+export function createCommentThread(comments: UIComment[]) {
+  comments = comments.map(comment => {
+    return {...createComment(), ...comment};
+  });
+  const threads = createCommentThreads(comments);
+  return threads.length > 0 ? threads[0] : {};
+}
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index 7ebc6e1..25366fe 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -16,12 +16,13 @@
  */
 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';
+import {appContext} from '../services/app-context';
+import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
 
 export interface MockPromise extends Promise<unknown> {
   resolve: (value?: unknown) => void;
@@ -87,7 +88,6 @@
 // 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([]);
@@ -105,6 +105,25 @@
   cleanups.push(cleanupCallback);
 }
 
+export function addListenerForTest(
+  el: EventTarget,
+  type: string,
+  listener: EventListenerOrEventListenerObject
+) {
+  el.addEventListener(type, listener);
+  registerListenerCleanup(el, type, listener);
+}
+
+export function registerListenerCleanup(
+  el: EventTarget,
+  type: string,
+  listener: EventListenerOrEventListenerObject
+) {
+  registerTestCleanup(() => {
+    el.removeEventListener(type, listener);
+  });
+}
+
 export function cleanupTestUtils() {
   cleanups.forEach(cleanup => cleanup());
   cleanups.splice(0);
@@ -116,19 +135,30 @@
   registerTestCleanup(() => (window.CANONICAL_PATH = originalCanonicalPath));
 }
 
+export function stubRestApi<K extends keyof RestApiService>(method: K) {
+  return sinon.stub(appContext.restApiService, method);
+}
+
+export function spyRestApi<K extends keyof RestApiService>(method: K) {
+  return sinon.spy(appContext.restApiService, method);
+}
+
 /**
  * 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.
+ * an animation to finish.
  */
-export function createIronOverlayBackdropStyleEl() {
-  const ironOverlayBackdropStyleEl = document.createElement('style');
-  document.head.appendChild(ironOverlayBackdropStyleEl);
-  ironOverlayBackdropStyleEl.sheet!.insertRule(
-    'body { --iron-overlay-backdrop-opacity: 0; }'
-  );
-  return ironOverlayBackdropStyleEl;
+export function addIronOverlayBackdropStyleEl() {
+  const el = document.createElement('style');
+  el.setAttribute('id', 'backdrop-style');
+  document.head.appendChild(el);
+  el.sheet!.insertRule('body { --iron-overlay-backdrop-opacity: 0; }');
+}
+
+export function removeIronOverlayBackdropStyleEl() {
+  const el = document.getElementById('backdrop-style');
+  if (!el?.parentNode) throw new Error('Backdrop style element not found.');
+  el.parentNode?.removeChild(el);
 }
 
 /**
@@ -139,7 +169,7 @@
  *   ...
  */
 export function listenOnce(el: EventTarget, eventType: string) {
-  return new Promise(resolve => {
+  return new Promise<void>(resolve => {
     const listener = () => {
       removeEventListener();
       resolve();
diff --git a/polygerrit-ui/app/tsconfig.json b/polygerrit-ui/app/tsconfig.json
index 15294f4..46d0173d 100644
--- a/polygerrit-ui/app/tsconfig.json
+++ b/polygerrit-ui/app/tsconfig.json
@@ -48,7 +48,8 @@
   "include": [
     // 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)
+    // (include and exclude arrays are overridden when extends)
+    "api/**/*",
     "constants/**/*",
     "elements/**/*",
     "embed/**/*",
diff --git a/polygerrit-ui/app/tsconfig_bazel.json b/polygerrit-ui/app/tsconfig_bazel.json
index 6365bf0..dfd2078 100644
--- a/polygerrit-ui/app/tsconfig_bazel.json
+++ b/polygerrit-ui/app/tsconfig_bazel.json
@@ -9,7 +9,8 @@
   "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)
+    // (include and exclude arrays are overridden when extends)
+    "api/**/*",
     "constants/**/*",
     "elements/**/*",
     "embed/**/*",
diff --git a/polygerrit-ui/app/tsconfig_bazel_test.json b/polygerrit-ui/app/tsconfig_bazel_test.json
index efd2978..9c2ff93 100644
--- a/polygerrit-ui/app/tsconfig_bazel_test.json
+++ b/polygerrit-ui/app/tsconfig_bazel_test.json
@@ -14,7 +14,8 @@
   "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)
+    // (include and exclude arrays are overridden when extends)
+    "api/**/*",
     "constants/**/*",
     "elements/**/*",
     "embed/**/*",
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 94b075d..1bfabf8 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 
+import {CommentRange} from '../api/core';
 import {
   ChangeStatus,
   DefaultDisplayNameConfig,
@@ -37,7 +38,6 @@
   TimeFormat,
   EmailStrategy,
   DefaultBase,
-  IgnoreWhitespaceType,
   UserPriority,
   DiffViewMode,
   DraftsAction,
@@ -50,6 +50,10 @@
 } from '../constants/constants';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 
+import {DiffInfo, IgnoreWhitespaceType, WebLinkInfo} from './diff';
+
+export {CommentRange};
+
 export type BrandType<T, BrandName extends string> = T &
   {[__brand in BrandName]: never};
 
@@ -72,6 +76,7 @@
 export type ParsedJSON = BrandType<unknown, '_parsedJSON'>;
 
 export type PatchSetNum = BrandType<'PARENT' | 'edit' | number, '_patchSet'>;
+export type PatchSetNumber = BrandType<number, '_patchSet'>;
 
 export const EditPatchSetNum = 'edit' as PatchSetNum;
 // TODO(TS): This is not correct, it is better to have a separate ApiPatchSetNum
@@ -194,7 +199,7 @@
   // This is not set when the change has no reviewers.
   all?: ApprovalInfo[];
   // Docs claim that 'values' is optional, but it is actually always set.
-  values: LabelValueToDescriptionMap; // A map of all values that are allowed for this label
+  values?: LabelValueToDescriptionMap; // A map of all values that are allowed for this label
   default_value?: number;
 }
 
@@ -261,7 +266,6 @@
   deletions: number; // Number of deleted lines
   total_comment_count?: number;
   unresolved_comment_count?: number;
-  // TODO(TS): Use changed_id everywhere in code instead of (legacy) _number
   _number: NumericChangeId;
   owner: AccountInfo;
   actions?: ActionNameToActionInfoMap;
@@ -716,16 +720,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
@@ -1028,7 +1022,7 @@
  */
 export interface PluginConfigInfo {
   has_avatars: boolean;
-  // The following 2 properies exists in Java class, but don't mention in docs
+  // The following 2 properties exists in Java class, but don't mention in docs
   js_resource_paths: string[];
   html_resource_paths: string[];
 }
@@ -1180,19 +1174,18 @@
   unresolved?: boolean;
   change_message_id?: string;
   commit_id?: string;
+  context_lines?: ContextLine[];
 }
 
 export type PathToCommentsInfoMap = {[path: string]: CommentInfo[]};
 
 /**
- * The CommentRange entity describes the range of an inline comment.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-range
+ * The ContextLine entity contains the line number and line text of a single line of the source file content..
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#context-line
  */
-export interface CommentRange {
-  start_line: number;
-  start_character: number;
-  end_line: number;
-  end_character: number;
+export interface ContextLine {
+  line_number: number;
+  context_line: string;
 }
 
 /**
@@ -1235,103 +1228,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?: number;
-  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
  */
@@ -1490,7 +1389,7 @@
   can_add_tags?: boolean;
   config_visible?: boolean;
   groups: ProjectAccessGroups;
-  config_web_links: string[];
+  config_web_links: WebLinkInfo[];
 }
 
 export type ProjectAccessInfoMap = {[projectName: string]: ProjectAccessInfo};
@@ -1716,7 +1615,7 @@
 }
 
 /**
- * Defines a patch ranges. Used as input for gr-rest-api-interface methods,
+ * Defines a patch ranges. Used as input for gr-rest-api methods,
  * doesn't exist in Rest API
  */
 export interface PatchRange {
@@ -1782,7 +1681,6 @@
   context?: number;
   expand_all_comments?: boolean;
   ignore_whitespace: IgnoreWhitespaceType;
-  intraline_difference?: boolean;
   line_length?: number;
   manual_review?: boolean;
   retain_header?: boolean;
@@ -1790,10 +1688,8 @@
   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;
@@ -1825,8 +1721,8 @@
   flushCaches?: boolean;
   killTask?: boolean;
   maintainServer?: boolean;
-  priority: UserPriority;
-  queryLimit: QueryLimitInfo;
+  priority?: UserPriority;
+  queryLimit?: QueryLimitInfo;
   runAs?: boolean;
   runGC?: boolean;
   streamEvents?: boolean;
diff --git a/polygerrit-ui/app/types/diff.ts b/polygerrit-ui/app/types/diff.ts
new file mode 100644
index 0000000..c3b38c6
--- /dev/null
+++ b/polygerrit-ui/app/types/diff.ts
@@ -0,0 +1,110 @@
+/**
+ * @fileoverview Types related to diffing.
+ *
+ * As gr-diff is an embeddable component, many of these types are actually
+ * defined in api/diff.ts. This file re-exports them and adds any
+ * internal fields that Gerrit may use.
+ *
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export {
+  ChangeType,
+  MoveDetails,
+  SkipLength,
+  MarkLength,
+  DiffIntralineInfo,
+  IgnoreWhitespaceType,
+} from '../api/diff';
+
+import {
+  DiffInfo as DiffInfoApi,
+  DiffFileMetaInfo as DiffFileMetaInfoApi,
+  DiffContent as DiffContentApi,
+  DiffPreferencesInfo as DiffPreferenceInfoApi,
+} from '../api/diff';
+
+export interface DiffInfo extends DiffInfoApi {
+  /** 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;
+
+  /** A list of strings representing the patch set diff header. */
+  diff_header?: string[];
+
+  /**
+   * Links to the file diff in external sites as a list of DiffWebLinkInfo
+   * entries.
+   */
+  web_links?: DiffWebLinkInfo[];
+}
+
+/**
+ * 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;
+}
+
+export interface DiffFileMetaInfo extends DiffFileMetaInfoApi {
+  /** Links to the file in external sites as a list of WebLinkInfo entries. */
+  web_links?: WebLinkInfo[];
+}
+
+/**
+ * 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;
+}
+
+export interface DiffContent extends DiffContentApi {
+  // TODO: Undocumented, but used in code.
+  keyLocation?: boolean;
+}
+
+export interface DiffPreferencesInfo extends DiffPreferenceInfoApi {
+  expand_all_comments?: boolean;
+  cursor_blink_rate: number;
+  manual_review?: boolean;
+  retain_header?: boolean;
+  skip_deleted?: boolean;
+  hide_top_menu?: boolean;
+  hide_line_numbers?: boolean;
+  hide_empty_pane?: boolean;
+  match_brackets?: boolean;
+  line_wrapping?: boolean;
+}
+
+export declare type DiffPreferencesInfoKey = keyof DiffPreferencesInfo;
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index 529904a..5965453 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -15,8 +15,11 @@
  * limitations under the License.
  */
 import {EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PatchSetNum} from './common';
+import {PatchSetNum, UrlEncodedCommentId} from './common';
 import {UIComment} from '../utils/comment-util';
+import {FetchRequest} from './types';
+import {MovedLinkClickedEventDetail} from '../api/diff';
+import {Category, RunStatus} from '../api/checks';
 
 export interface TitleChangeEventDetail {
   title: string;
@@ -37,11 +40,36 @@
 export type PageErrorEvent = CustomEvent<PageErrorEventDetail>;
 
 declare global {
-  interface HTMLElementEventMap {
+  interface DocumentEventMap {
     'page-error': PageErrorEvent;
   }
 }
 
+export interface ServerErrorEventDetail {
+  request?: FetchRequest;
+  response: Response;
+}
+
+export type ServerErrorEvent = CustomEvent<ServerErrorEventDetail>;
+
+declare global {
+  interface DocumentEventMap {
+    'server-error': ServerErrorEvent;
+  }
+}
+
+export interface NetworkErrorEventDetail {
+  error: Error;
+}
+
+export type NetworkErrorEvent = CustomEvent<NetworkErrorEventDetail>;
+
+declare global {
+  interface DocumentEventMap {
+    'network-error': NetworkErrorEvent;
+  }
+}
+
 export interface LocationChangeEventDetail {
   hash: string;
   pathname: string;
@@ -65,8 +93,8 @@
 export type RpcLogEvent = CustomEvent<RpcLogEventDetail>;
 
 declare global {
-  interface HTMLElementEventMap {
-    'rpc-log': RpcLogEvent;
+  interface DocumentEventMap {
+    'gr-rpc-log': RpcLogEvent;
   }
 }
 
@@ -119,6 +147,24 @@
   value?: number;
   // scroll into the tab afterwards, from custom event
   scrollIntoView?: boolean;
+  // define state of tab after opening
+  tabState?: TabState;
+}
+
+export interface TabState {
+  commentTab?: CommentTabState;
+  checksTab?: ChecksTabState;
+}
+
+export enum CommentTabState {
+  UNRESOLVED = 'unresolved',
+  DRAFTS = 'drafts',
+  SHOW_ALL = 'show all',
+}
+
+export interface ChecksTabState {
+  statusOrCategory?: RunStatus | Category;
+  checkName?: string;
 }
 
 export type SwitchTabEvent = CustomEvent<SwitchTabEventDetail>;
@@ -136,14 +182,27 @@
 
 export type ReloadEvent = CustomEvent<ReloadEventDetail>;
 
+export type MovedLinkClickedEvent = CustomEvent<MovedLinkClickedEventDetail>;
+
 declare global {
   interface HTMLElementEventMap {
-    reload: ReloadEvent;
+    'moved-link-clicked': MovedLinkClickedEvent;
+  }
+}
+
+declare global {
+  interface HTMLElementEventMap {
+    /* prettier-ignore */
+    'reload': ReloadEvent;
   }
 }
 
 export interface ShowAlertEventDetail {
   message: string;
+  dismissOnNavigation?: boolean;
+  showDismiss?: boolean;
+  action?: string;
+  callback?: () => void;
 }
 
 export type ShowAlertEvent = CustomEvent<ShowAlertEventDetail>;
@@ -172,3 +231,25 @@
   readonly keyCode: number;
   readonly repeat: boolean;
 }
+
+export interface ThreadListModifiedDetail {
+  rootId: UrlEncodedCommentId;
+  path: string;
+}
+
+export type ThreadListModifiedEvent = CustomEvent<ThreadListModifiedDetail>;
+
+// TODO(milutin) - remove once new gr-dialog will do it out of the box
+// This informs gr-app-element to remove footer, header from a11y tree
+export interface DialogChangeEventDetail {
+  canceled?: boolean;
+  opened?: boolean;
+}
+
+export type DialogChangeEvent = CustomEvent<DialogChangeEventDetail>;
+
+declare global {
+  interface HTMLElementEventMap {
+    'thread-list-modified': ThreadListModifiedEvent;
+  }
+}
diff --git a/polygerrit-ui/app/types/globals.ts b/polygerrit-ui/app/types/globals.ts
index 2962158..628cee4 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,
@@ -42,12 +36,7 @@
       Auth?: unknown;
       _pluginLoader?: unknown;
       _endpoints?: unknown;
-      slotToContent?: unknown;
-      rangesEqual?: unknown;
-      SUGGESTIONS_PROVIDERS_USERS_TYPES?: unknown;
       RevisionInfo?: unknown;
-      CoverageType?: unknown;
-      hiddenscroll?: unknown;
       flushPreinstalls?: () => void;
     };
     // TODO(TS): define polymer type
@@ -75,49 +64,16 @@
     // 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: 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;
+    // Heads up! There is a known plugin dependency on GrPluginActionContext.
     GrPluginActionContext: unknown;
     _apiUtils: {};
   }
@@ -132,13 +88,6 @@
     };
   }
 
-  interface Event {
-    // path is a non-standard property. Actually, this is optional property,
-    // but marking it as optional breaks CustomKeyboardEvent
-    // TODO(TS): replace with composedPath if possible
-    readonly path: EventTarget[];
-  }
-
   interface Error {
     lineNumber?: number; // non-standard property
     columnNumber?: number; // non-standard property
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
index b40d618..be80411 100644
--- a/polygerrit-ui/app/types/types.ts
+++ b/polygerrit-ui/app/types/types.ts
@@ -14,19 +14,26 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {DiffViewMode, Side} from '../constants/constants';
+import {DiffLayer as DiffLayerApi} from '../api/diff';
+import {DiffViewMode, MessageTag, Side} from '../constants/constants';
 import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
-import {GrDiffLine} from '../elements/diff/gr-diff/gr-diff-line';
 import {FlattenedNodesObserver} from '@polymer/polymer/lib/utils/flattened-nodes-observer';
 import {PaperInputElement} from '@polymer/paper-input/paper-input';
 import {
+  AccountInfo,
   ChangeId,
+  ChangeViewChangeInfo,
   CommitId,
+  CommitInfo,
   NumericChangeId,
   PatchRange,
   PatchSetNum,
+  ReviewerUpdateInfo,
+  RevisionInfo,
+  Timestamp,
 } from './common';
 import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
+import {AuthRequestInit} from '../services/gr-auth/gr-auth';
 
 export function notUndefined<T>(x: T | undefined): x is T {
   return x !== undefined;
@@ -41,31 +48,7 @@
   commit: CommitId;
 }
 
-export interface CoverageRange {
-  type: CoverageType;
-  side: Side;
-  code_range: {end_line: number; start_line: number};
-}
-
-export enum CoverageType {
-  /**
-   * start_character and end_character of the range will be ignored for this
-   * type.
-   */
-  COVERED = 'COVERED',
-  /**
-   * start_character and end_character of the range will be ignored for this
-   * type.
-   */
-  NOT_COVERED = 'NOT_COVERED',
-  PARTIALLY_COVERED = 'PARTIALLY_COVERED',
-  /**
-   * You don't have to use this. If there is no coverage information for a
-   * range, then it implicitly means NOT_INSTRUMENTED. start_character and
-   * end_character of the range will be ignored for this type.
-   */
-  NOT_INSTRUMENTED = 'NOT_INSTRUMENTED',
-}
+export {CoverageRange, CoverageType} from '../api/diff';
 
 export enum ErrorType {
   AUTH = 'AUTH',
@@ -167,8 +150,7 @@
   side: Side
 ) => void;
 
-export interface DiffLayer {
-  annotate(el: HTMLElement, lineEl: HTMLElement, line: GrDiffLine): void;
+export interface DiffLayer extends DiffLayerApi {
   addListener?(listener: DiffLayerListener): void;
   removeListener?(listener: DiffLayerListener): void;
 }
@@ -201,7 +183,7 @@
 }
 
 export interface DashboardViewState {
-  selectedChangeIndex: number;
+  [key: string]: number;
 }
 
 export interface ViewState {
@@ -237,3 +219,30 @@
 >(x: T | PolymerSpliceChange<U>): x is PolymerSpliceChange<U> {
   return (x as PolymerSpliceChange<U>).indexSplices !== undefined;
 }
+
+export interface FetchRequest {
+  url: string;
+  fetchOptions?: AuthRequestInit;
+  anonymizedUrl?: string;
+}
+
+export interface FormattedReviewerUpdateInfo {
+  author: AccountInfo;
+  date: Timestamp;
+  type: 'REVIEWER_UPDATE';
+  tag: MessageTag.TAG_REVIEWER_UPDATE;
+  updates: {message: string; reviewers: AccountInfo[]}[];
+}
+
+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' | 'revisions'> {
+  revisions: {[revisionId: string]: RevisionInfo | EditRevisionInfo};
+  reviewer_updates?: ReviewerUpdateInfo[] | FormattedReviewerUpdateInfo[];
+}
diff --git a/polygerrit-ui/app/utils/account-util.ts b/polygerrit-ui/app/utils/account-util.ts
index 7e425f8..bb9f328 100644
--- a/polygerrit-ui/app/utils/account-util.ts
+++ b/polygerrit-ui/app/utils/account-util.ts
@@ -28,6 +28,24 @@
   return !!account?.tags?.includes(AccountTag.SERVICE_USER);
 }
 
+export function isSelf(account?: AccountInfo, self?: AccountInfo): boolean {
+  return account?._account_id === self?._account_id;
+}
+
 export function removeServiceUsers(accounts?: AccountInfo[]): AccountInfo[] {
   return accounts?.filter(a => !isServiceUser(a)) || [];
 }
+
+export function hasSameAvatar(account?: AccountInfo, other?: AccountInfo) {
+  return account?.avatars?.[0]?.url === other?.avatars?.[0]?.url;
+}
+
+export function uniqueDefinedAvatar(
+  account: AccountInfo,
+  index: number,
+  accountArray: AccountInfo[]
+) {
+  return (
+    index === accountArray.findIndex(other => hasSameAvatar(account, other))
+  );
+}
diff --git a/polygerrit-ui/app/utils/admin-nav-util.ts b/polygerrit-ui/app/utils/admin-nav-util.ts
index 06f4e3a..275f9e6 100644
--- a/polygerrit-ui/app/utils/admin-nav-util.ts
+++ b/polygerrit-ui/app/utils/admin-nav-util.ts
@@ -16,7 +16,6 @@
  */
 import {
   GerritNav,
-  GerritView,
   RepoDetailView,
   GroupDetailView,
 } from '../elements/core/gr-navigation/gr-navigation';
@@ -26,8 +25,9 @@
   AccountDetailInfo,
   AccountCapabilityInfo,
 } from '../types/common';
-import {MenuLink} from '../elements/plugins/gr-admin-api/gr-admin-api';
 import {hasOwnProperty} from './common-util';
+import {GerritView} from '../services/router/router-model';
+import {MenuLink} from '../api/admin';
 
 const ADMIN_LINKS: NavLink[] = [
   {
diff --git a/polygerrit-ui/app/utils/attention-set-util.ts b/polygerrit-ui/app/utils/attention-set-util.ts
index b0aefcb..5b2762c 100644
--- a/polygerrit-ui/app/utils/attention-set-util.ts
+++ b/polygerrit-ui/app/utils/attention-set-util.ts
@@ -17,6 +17,7 @@
 
 import {AccountInfo, ChangeInfo} from '../types/common';
 import {isServiceUser} from './account-util';
+import {hasOwnProperty} from './common-util';
 
 // You would typically use a ServerInfo here, but this utility does not care
 // about all the other parameters in that object.
@@ -46,7 +47,8 @@
   return (
     isAttentionSetEnabled(config) &&
     canHaveAttention(account) &&
-    !!change?.attention_set?.hasOwnProperty(account!._account_id!)
+    !!change?.attention_set &&
+    hasOwnProperty(change?.attention_set, account!._account_id!)
   );
 }
 
diff --git a/polygerrit-ui/app/utils/change-metadata-util.ts b/polygerrit-ui/app/utils/change-metadata-util.ts
new file mode 100644
index 0000000..c3eb846
--- /dev/null
+++ b/polygerrit-ui/app/utils/change-metadata-util.ts
@@ -0,0 +1,75 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {ParsedChangeInfo} from '../types/types';
+
+export enum Metadata {
+  OWNER = 'Owner',
+  REVIEWERS = 'Reviewers',
+  REPO_BRANCH = 'Repo | Branch',
+  SUBMITTED = 'Submitted',
+  PARENT = 'Parent',
+  STRATEGY = 'Strategy',
+  UPDATED = 'Updated',
+  CC = 'CC',
+  HASHTAGS = 'Hashtags',
+  TOPIC = 'Topic',
+  UPLOADER = 'Uploader',
+  AUTHOR = 'Author',
+  COMMITTER = 'Committer',
+  ASSIGNEE = 'Assignee',
+  CHERRY_PICK_OF = 'Cherry pick of',
+}
+
+export const DisplayRules = {
+  ALWAYS_SHOW: [
+    Metadata.OWNER,
+    Metadata.REVIEWERS,
+    Metadata.REPO_BRANCH,
+    Metadata.SUBMITTED,
+  ],
+  SHOW_IF_SET: [
+    Metadata.CC,
+    Metadata.HASHTAGS,
+    Metadata.TOPIC,
+    Metadata.UPLOADER,
+    Metadata.AUTHOR,
+    Metadata.COMMITTER,
+    Metadata.ASSIGNEE,
+    Metadata.CHERRY_PICK_OF,
+  ],
+  ALWAYS_HIDE: [Metadata.PARENT, Metadata.STRATEGY, Metadata.UPDATED],
+};
+
+export function isSectionSet(section: Metadata, change?: ParsedChangeInfo) {
+  switch (section) {
+    case Metadata.CC:
+      return !!change?.reviewers?.CC?.length;
+    case Metadata.HASHTAGS:
+      return !!change?.hashtags?.length;
+    case Metadata.TOPIC:
+      return !!change?.topic;
+    case Metadata.UPLOADER:
+    case Metadata.AUTHOR:
+    case Metadata.COMMITTER:
+    case Metadata.ASSIGNEE:
+      return false;
+    case Metadata.CHERRY_PICK_OF:
+      return !!change?.cherry_pick_of_change;
+  }
+  return true;
+}
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index 47924e6..a7f8b49 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -21,8 +21,9 @@
   PatchSetNum,
   ChangeInfo,
   AccountInfo,
+  RelatedChangeAndCommitInfo,
 } from '../types/common';
-import {ParsedChangeInfo} from '../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {ParsedChangeInfo} from '../types/types';
 
 // This can be wrong! See WARNING above
 interface ChangeStatusesOptions {
@@ -172,11 +173,60 @@
   return states;
 }
 
-export function isOwner(change?: ChangeInfo, account?: AccountInfo) {
+export function isOwner(change?: ChangeInfo, account?: AccountInfo): boolean {
   if (!change || !account) return false;
   return change.owner?._account_id === account._account_id;
 }
 
+export function isReviewer(
+  change?: ChangeInfo,
+  account?: AccountInfo
+): boolean {
+  if (!change || !account) return false;
+  const reviewers = change.reviewers.REVIEWER ?? [];
+  return reviewers.some(r => r._account_id === account._account_id);
+}
+
+export function isCc(change?: ChangeInfo, account?: AccountInfo): boolean {
+  if (!change || !account) return false;
+  const ccs = change.reviewers.CC ?? [];
+  return ccs.some(r => r._account_id === account._account_id);
+}
+
+export function isUploader(
+  change?: ChangeInfo,
+  account?: AccountInfo
+): boolean {
+  if (!change || !account) return false;
+  const rev = getCurrentRevision(change);
+  return rev?.uploader?._account_id === account._account_id;
+}
+
+export function isInvolved(
+  change?: ChangeInfo,
+  account?: AccountInfo
+): boolean {
+  const owner = isOwner(change, account);
+  const uploader = isUploader(change, account);
+  const reviewer = isReviewer(change, account);
+  const cc = isCc(change, account);
+  return owner || uploader || reviewer || cc;
+}
+
+export function getCurrentRevision(change?: ChangeInfo) {
+  if (!change?.revisions || !change?.current_revision) return undefined;
+  return change.revisions[change.current_revision];
+}
+
+export function getRevisionKey(
+  change: ChangeInfo | ParsedChangeInfo,
+  patchNum: PatchSetNum
+) {
+  return Object.keys(change.revisions ?? []).find(
+    rev => change?.revisions?.[rev]._number === patchNum
+  );
+}
+
 export function changeStatusString(change: ChangeInfo) {
   return changeStatuses(change).join(', ');
 }
@@ -192,3 +242,9 @@
       (!reviewer._account_id && account.email === reviewer.email)
   );
 }
+
+export function isChangeInfo(
+  x: ChangeInfo | RelatedChangeAndCommitInfo | ParsedChangeInfo
+): x is ChangeInfo | ParsedChangeInfo {
+  return (x as ChangeInfo)._number !== undefined;
+}
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index 5f8aa82..de12a2a 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -21,9 +21,17 @@
   RobotCommentInfo,
   Timestamp,
   UrlEncodedCommentId,
+  CommentRange,
+  PatchRange,
+  ParentPatchSetNum,
+  ContextLine,
 } 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';
+import {isMergeParent, getParentIndex} from './patch-set-util';
+import {DiffInfo} from '../types/diff';
 
 export interface DraftCommentProps {
   __draft?: boolean;
@@ -39,19 +47,14 @@
 export type Comment = DraftInfo | CommentInfo | RobotCommentInfo;
 
 export interface UIStateCommentProps {
-  // The `side` of the comment is PARENT or REVISION, but this is LEFT or RIGHT.
-  // TODO(TS): Remove the naming confusion of commentSide being of type of Side,
-  // but side being of type CommentSide. :-)
-  __commentSide?: Side;
-  // TODO(TS): Remove this. Seems to be exactly the same as `path`??
-  __path?: string;
   collapsed?: boolean;
-  // TODO(TS): Consider allowing this only for drafts.
-  __editing?: boolean;
-  __otherEditing?: boolean;
 }
 
-export type UIDraft = DraftInfo & UIStateCommentProps;
+export interface UIStateDraftProps {
+  __editing?: boolean;
+}
+
+export type UIDraft = DraftInfo & UIStateCommentProps & UIStateDraftProps;
 
 export type UIHuman = CommentInfo & UIStateCommentProps;
 
@@ -97,17 +100,74 @@
   });
 }
 
+export function createCommentThreads(
+  comments: UIComment[],
+  patchRange?: PatchRange
+) {
+  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) {
+      throw new Error('Comment missing required "path".');
+    }
+    const newThread: CommentThread = {
+      comments: [comment],
+      patchNum: comment.patch_set,
+      commentSide: comment.side ?? CommentSide.REVISION,
+      mergeParentNum: comment.parent,
+      path: comment.path,
+      line: comment.line,
+      range: comment.range,
+      rootId: comment.id,
+    };
+    if (patchRange) {
+      if (isInBaseOfPatchRange(comment, patchRange))
+        newThread.diffSide = Side.LEFT;
+      else if (isInRevisionOfPatchRange(comment, patchRange))
+        newThread.diffSide = Side.RIGHT;
+      else throw new Error('comment does not belong in given patchrange');
+    }
+    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;
+  /* mergeParentNum is the merge parent number only valid for merge commits
+     when commentSide is PARENT.
+     mergeParentNum is undefined for auto merge commits
+  */
+  mergeParentNum?: number;
+  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;
+  ported?: boolean; // is the comment ported over from a previous patchset
+  rangeInfoLost?: boolean; // if BE was unable to determine a range for this
 }
 
 export function getLastComment(thread?: CommentThread): UIComment | undefined {
@@ -115,10 +175,146 @@
   return thread && len ? thread.comments[len - 1] : undefined;
 }
 
+export function getFirstComment(thread?: CommentThread): UIComment | undefined {
+  return thread?.comments?.[0];
+}
+
+export function countComments(thread?: CommentThread) {
+  return thread?.comments?.length ?? 0;
+}
+
 export function isUnresolved(thread?: CommentThread): boolean {
-  return !!getLastComment(thread)?.unresolved;
+  return !isResolved(thread);
+}
+
+export function isResolved(thread?: CommentThread): boolean {
+  return !getLastComment(thread)?.unresolved;
 }
 
 export function isDraftThread(thread?: CommentThread): boolean {
   return isDraft(getLastComment(thread));
 }
+
+export function isRobotThread(thread?: CommentThread): boolean {
+  return isRobot(getFirstComment(thread));
+}
+
+export function hasHumanReply(thread?: CommentThread): boolean {
+  return countComments(thread) > 1 && !isRobot(getLastComment(thread));
+}
+
+/**
+ * Whether the given comment should be included in the base side of the
+ * given patch range.
+ */
+export function isInBaseOfPatchRange(
+  comment: CommentBasics,
+  range: PatchRange
+) {
+  // If the base of the patch range is a parent of a merge, and the comment
+  // appears on a specific parent then only show the comment if the parent
+  // index of the comment matches that of the range.
+  if (comment.parent && comment.side === CommentSide.PARENT) {
+    return (
+      isMergeParent(range.basePatchNum) &&
+      comment.parent === getParentIndex(range.basePatchNum)
+    );
+  }
+
+  // If the base of the range is the parent of the patch:
+  if (
+    range.basePatchNum === ParentPatchSetNum &&
+    comment.side === CommentSide.PARENT &&
+    comment.patch_set === range.patchNum
+  ) {
+    return true;
+  }
+  // If the base of the range is not the parent of the patch:
+  return (
+    range.basePatchNum !== ParentPatchSetNum &&
+    comment.side !== CommentSide.PARENT &&
+    comment.patch_set === range.basePatchNum
+  );
+}
+
+/**
+ * Whether the given comment should be included in the revision side of the
+ * given patch range.
+ */
+export function isInRevisionOfPatchRange(
+  comment: CommentBasics,
+  range: PatchRange
+) {
+  return (
+    comment.side !== CommentSide.PARENT && comment.patch_set === range.patchNum
+  );
+}
+
+/**
+ * Whether the given comment should be included in the given patch range.
+ */
+export function isInPatchRange(
+  comment: CommentBasics,
+  range: PatchRange
+): boolean {
+  return (
+    isInBaseOfPatchRange(comment, range) ||
+    isInRevisionOfPatchRange(comment, range)
+  );
+}
+
+export function getPatchRangeForCommentUrl(
+  comment: UIComment,
+  latestPatchNum: PatchSetNum
+) {
+  if (!comment.patch_set) throw new Error('Missing comment.patch_set');
+
+  // TODO(dhruvsri): Add handling for comment left on parents of merge commits
+  if (comment.side === CommentSide.PARENT) {
+    return {
+      patchNum: comment.patch_set,
+      basePatchNum: ParentPatchSetNum,
+    };
+  } else if (latestPatchNum === comment.patch_set) {
+    return {
+      patchNum: latestPatchNum,
+      basePatchNum: ParentPatchSetNum,
+    };
+  } else {
+    return {
+      patchNum: latestPatchNum,
+      basePatchNum: comment.patch_set,
+    };
+  }
+}
+
+export function computeDiffFromContext(context: ContextLine[], path: string) {
+  // do not render more than 20 lines of context
+  context = context.slice(0, 20);
+  const diff: DiffInfo = {
+    meta_a: {
+      name: '',
+      content_type: '',
+      lines: 0,
+      web_links: [],
+    },
+    meta_b: {
+      name: path,
+      content_type: '',
+      lines: context.length + context?.[0].line_number,
+      web_links: [],
+    },
+    change_type: 'MODIFIED',
+    intraline_status: 'OK',
+    diff_header: [],
+    content: [
+      {
+        skip: context[0].line_number - 1,
+      },
+      {
+        b: context.map(line => line.context_line),
+      },
+    ],
+  };
+  return diff;
+}
diff --git a/polygerrit-ui/app/utils/comment-util_test.js b/polygerrit-ui/app/utils/comment-util_test.js
index ad19974..4ce95ef 100644
--- a/polygerrit-ui/app/utils/comment-util_test.js
+++ b/polygerrit-ui/app/utils/comment-util_test.js
@@ -17,8 +17,11 @@
 
 import '../test/common-test-setup-karma.js';
 import {
-  isUnresolved,
+  isUnresolved, getPatchRangeForCommentUrl, createCommentThreads, sortComments,
 } from './comment-util.js';
+import {createComment} from '../test/test-data-generators.js';
+import {CommentSide, Side} from '../constants/constants.js';
+import {ParentPatchSetNum} from '../types/common.js';
 
 suite('comment-util', () => {
   test('isUnresolved', () => {
@@ -31,4 +34,173 @@
     assert.isFalse(isUnresolved(
         {comments: [{unresolved: true}, {unresolved: false}]}));
   });
+
+  test('getPatchRangeForCommentUrl', () => {
+    test('comment created with side=PARENT does not navigate to latest ps',
+        () => {
+          const comment = {
+            ...createComment(),
+            id: 'c4',
+            line: 10,
+            patch_set: 4,
+            side: CommentSide.PARENT,
+            path: '/COMMIT_MSG',
+          };
+          assert.deepEqual(getPatchRangeForCommentUrl(comment, 11), {
+            basePatchNum: ParentPatchSetNum,
+            patchNum: 4,
+          });
+        });
+  });
+
+  test('comments sorting', () => {
+    const comments = [
+      {
+        id: 'new_draft',
+        message: 'i do not like either of you',
+        diffSide: Side.LEFT,
+        __draft: true,
+        updated: '2015-12-20 15:01:20.396000000',
+      },
+      {
+        id: 'sallys_confession',
+        message: 'i like you, jack',
+        updated: '2015-12-23 15:00:20.396000000',
+        line: 1,
+        diffSide: Side.LEFT,
+      }, {
+        id: 'jacks_reply',
+        message: 'i like you, too',
+        updated: '2015-12-24 15:01:20.396000000',
+        diffSide: Side.LEFT,
+        line: 1,
+        in_reply_to: 'sallys_confession',
+      },
+    ];
+    const sortedComments = sortComments(comments);
+    assert.equal(sortedComments[0], comments[1]);
+    assert.equal(sortedComments[1], comments[2]);
+    assert.equal(sortedComments[2], comments[0]);
+  });
+
+  suite('createCommentThreads', () => {
+    test('creates threads from individual comments', () => {
+      const comments = [
+        {
+          id: 'sallys_confession',
+          message: 'i like you, jack',
+          updated: '2015-12-23 15:00:20.396000000',
+          line: 1,
+          patch_set: 1,
+          path: 'some/path',
+        }, {
+          id: 'jacks_reply',
+          message: 'i like you, too',
+          updated: '2015-12-24 15:01:20.396000000',
+          line: 1,
+          in_reply_to: 'sallys_confession',
+          patch_set: 1,
+          path: 'some/path',
+        },
+        {
+          id: 'new_draft',
+          message: 'i do not like either of you',
+          __draft: true,
+          updated: '2015-12-20 15:01:20.396000000',
+          patch_set: 1,
+          path: 'some/path',
+        },
+      ];
+
+      const actualThreads = createCommentThreads(comments,
+          {basePatchNum: 1, patchNum: 4});
+
+      assert.equal(actualThreads.length, 2);
+
+      assert.equal(actualThreads[0].diffSide, Side.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, 1);
+      assert.equal(actualThreads[0].line, 1);
+
+      assert.equal(actualThreads[1].diffSide, Side.LEFT);
+      assert.equal(actualThreads[1].comments.length, 1);
+      assert.deepEqual(actualThreads[1].comments[0], comments[2]);
+      assert.equal(actualThreads[1].patchNum, 1);
+      assert.equal(actualThreads[1].line, 'FILE');
+    });
+
+    test('derives patchNum and range', () => {
+      const comments = [{
+        id: 'betsys_confession',
+        message: 'i like you, jack',
+        updated: '2015-12-24 15:00:10.396000000',
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 1,
+          end_character: 2,
+        },
+        patch_set: 5,
+        path: '/p',
+        line: 1,
+      }];
+
+      const expectedThreads = [
+        {
+          diffSide: Side.LEFT,
+          commentSide: CommentSide.REVISION,
+          path: '/p',
+          rootId: 'betsys_confession',
+          mergeParentNum: undefined,
+          comments: [{
+            id: 'betsys_confession',
+            path: '/p',
+            message: 'i like you, jack',
+            updated: '2015-12-24 15:00:10.396000000',
+            range: {
+              start_line: 1,
+              start_character: 1,
+              end_line: 1,
+              end_character: 2,
+            },
+            patch_set: 5,
+            line: 1,
+          }],
+          patchNum: 5,
+          range: {
+            start_line: 1,
+            start_character: 1,
+            end_line: 1,
+            end_character: 2,
+          },
+          line: 1,
+        },
+      ];
+
+      assert.deepEqual(
+          createCommentThreads(comments, {basePatchNum: 5, patchNum: 10}),
+          expectedThreads);
+    });
+
+    test('does not thread unrelated comments at same location', () => {
+      const comments = [
+        {
+          id: 'sallys_confession',
+          message: 'i like you, jack',
+          updated: '2015-12-23 15:00:20.396000000',
+          diffSide: Side.LEFT,
+          path: '/p',
+        }, {
+          id: 'jacks_reply',
+          message: 'i like you, too',
+          updated: '2015-12-24 15:01:20.396000000',
+          diffSide: Side.LEFT,
+          path: '/p',
+        },
+      ];
+      assert.equal(createCommentThreads(comments).length, 2);
+    });
+  });
 });
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
index 5b332ea..f4d6d51 100644
--- a/polygerrit-ui/app/utils/common-util.ts
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -47,6 +47,50 @@
 }
 
 /**
+ * Throws an error with the provided error message if the condition is false.
+ */
+export function check(
+  condition: boolean,
+  errorMessage: string
+): asserts condition {
+  if (!condition) throw new Error(errorMessage);
+}
+
+/**
+ * Throws an error if the property is not defined.
+ */
+export function checkProperty(
+  condition: boolean,
+  propertyName: string
+): asserts condition {
+  check(condition, `missing required property '${propertyName}'`);
+}
+
+/**
+ * Throws an error if the property is not defined.
+ */
+export function checkRequiredProperty<T>(
+  property: T,
+  propertyName: string
+): asserts property is NonNullable<T> {
+  if (property === undefined || property === null) {
+    throw new Error(`Required property '${propertyName}' not set.`);
+  }
+}
+
+/**
+ * Throws an error if the property is not defined.
+ */
+export function assertIsDefined<T>(
+  val: T,
+  variableName = 'variable'
+): asserts val is NonNullable<T> {
+  if (val === undefined || val === null) {
+    throw new Error(`${variableName} is not defined`);
+  }
+}
+
+/**
  * Returns true, if both sets contain the same members.
  */
 export function areSetsEqual<T>(a: Set<T>, b: Set<T>): boolean {
@@ -67,3 +111,14 @@
   }
   return true;
 }
+
+/**
+ * Add value, if the set does not contain it. Otherwise remove it.
+ */
+export function toggleSetMembership<T>(set: Set<T>, value: T): void {
+  if (set.has(value)) {
+    set.delete(value);
+  } else {
+    set.add(value);
+  }
+}
diff --git a/polygerrit-ui/app/utils/common-util_test.js b/polygerrit-ui/app/utils/common-util_test.js
index 917d652b..d6d66d7 100644
--- a/polygerrit-ui/app/utils/common-util_test.js
+++ b/polygerrit-ui/app/utils/common-util_test.js
@@ -29,7 +29,7 @@
       assert.isTrue(hasOwnProperty(obj, 'name with spaces'));
       assert.isFalse(hasOwnProperty(obj, 'def'));
     });
-    test('object prototype has overriden hasOwnProperty', () => {
+    test('object prototype has overridden hasOwnProperty', () => {
       const F = function() {
         this.abc = 23;
       };
diff --git a/polygerrit-ui/app/utils/date-util.ts b/polygerrit-ui/app/utils/date-util.ts
index 1dd2d2f..3af8c59 100644
--- a/polygerrit-ui/app/utils/date-util.ts
+++ b/polygerrit-ui/app/utils/date-util.ts
@@ -37,27 +37,31 @@
 
 // similar to fromNow from moment.js
 export function fromNow(date: Date, noAgo = false) {
-  const now = new Date();
+  return durationString(date, new Date(), noAgo);
+}
+
+// similar to fromNow from moment.js
+export function durationString(from: Date, to: Date, noAgo = false) {
   const ago = noAgo ? '' : ' ago';
-  const secondsAgo = Math.round((now.valueOf() - date.valueOf()) / 1000);
+  const secondsAgo = Math.floor((to.valueOf() - from.valueOf()) / 1000);
   if (secondsAgo <= 59) return 'just now';
   if (secondsAgo <= 119) return `1 minute${ago}`;
-  const minutesAgo = Math.round(secondsAgo / 60);
+  const minutesAgo = Math.floor(secondsAgo / 60);
   if (minutesAgo <= 59) return `${minutesAgo} minutes${ago}`;
   if (minutesAgo === 60) return `1 hour${ago}`;
   if (minutesAgo <= 119) return `1 hour ${minutesAgo - 60} min${ago}`;
-  const hoursAgo = Math.round(minutesAgo / 60);
+  const hoursAgo = Math.floor(minutesAgo / 60);
   if (hoursAgo <= 23) return `${hoursAgo} hours${ago}`;
   if (hoursAgo === 24) return `1 day${ago}`;
   if (hoursAgo <= 47) return `1 day ${hoursAgo - 24} hr${ago}`;
-  const daysAgo = Math.round(hoursAgo / 24);
+  const daysAgo = Math.floor(hoursAgo / 24);
   if (daysAgo <= 30) return `${daysAgo} days${ago}`;
   if (daysAgo <= 60) return `1 month${ago}`;
-  const monthsAgo = Math.round(daysAgo / 30);
+  const monthsAgo = Math.floor(daysAgo / 30);
   if (monthsAgo <= 11) return `${monthsAgo} months${ago}`;
   if (monthsAgo === 12) return `1 year${ago}`;
   if (monthsAgo <= 24) return `1 year ${monthsAgo - 12} m${ago}`;
-  const yearsAgo = Math.round(daysAgo / 365);
+  const yearsAgo = Math.floor(daysAgo / 365);
   return `${yearsAgo} years${ago}`;
 }
 
@@ -69,6 +73,16 @@
   return diff < Duration.DAY && date.getDay() === now.getDay();
 }
 
+export function wasYesterday(now: Date, date: Date) {
+  const diff = now.valueOf() - date.valueOf();
+  // return true if date is withing 24 hours and not on the same day
+  if (diff < Duration.DAY && date.getDay() !== now.getDay()) return true;
+
+  // move now to yesterday
+  now.setDate(now.getDate() - 1);
+  return isWithinDay(now, date);
+}
+
 /**
  * Returns true if date is from one to six months.
  */
diff --git a/polygerrit-ui/app/utils/date-util_test.js b/polygerrit-ui/app/utils/date-util_test.js
index a003c65..96d5bc1 100644
--- a/polygerrit-ui/app/utils/date-util_test.js
+++ b/polygerrit-ui/app/utils/date-util_test.js
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 import '../test/common-test-setup-karma.js';
-import {isValidDate, parseDate, fromNow, isWithinDay, isWithinHalfYear, formatDate} from './date-util.js';
+import {isValidDate, parseDate, fromNow, isWithinDay, isWithinHalfYear, formatDate, wasYesterday} from './date-util.js';
 
 suite('date-util tests', () => {
   suite('parseDate', () => {
@@ -53,6 +53,11 @@
       assert.equal('1 year ago', fromNow(new Date('May 05 2019 12:00:00')));
       assert.equal('10 years ago', fromNow(new Date('May 05 2010 12:00:00')));
     });
+    test('rounding error', () => {
+      const fakeNow = new Date('May 08 2020 12:00:00');
+      sinon.useFakeTimers(fakeNow.getTime());
+      assert.equal('2 hours ago', fromNow(new Date('May 08 2020 9:30:00')));
+    });
   });
 
   suite('isWithinDay', () => {
@@ -64,6 +69,21 @@
     });
   });
 
+  suite('wasYesterday', () => {
+    test('less 24 hours', () => {
+      assert.isFalse(wasYesterday(new Date('May 08 2020 12:00:00'),
+          new Date('May 08 2020 02:00:00')));
+      assert.isTrue(wasYesterday(new Date('May 08 2020 12:00:00'),
+          new Date('May 07 2020 12:00:00')));
+    });
+    test('more 24 hours', () => {
+      assert.isTrue(wasYesterday(new Date('May 08 2020 12:00:00'),
+          new Date('May 07 2020 2:00:00')));
+      assert.isFalse(wasYesterday(new Date('May 08 2020 12:00:00'),
+          new Date('May 06 2020 14:00:00')));
+    });
+  });
+
   suite('isWithinHalfYear', () => {
     test('basics works', () => {
       assert.isTrue(isWithinHalfYear(new Date('May 08 2020 12:00:00'),
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index 76db40b..7f9ef72 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -15,9 +15,8 @@
  * 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';
+import {check} from './common-util';
 
 /**
  * Event emitted from polymer elements.
@@ -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();
 }
 
 /**
@@ -173,6 +154,12 @@
   return [...results];
 }
 
+export function windowLocationReload() {
+  const e = new Error();
+  console.info(`Calling window.location.reload(): ${e.stack}`);
+  window.location.reload();
+}
+
 /**
  * Retrieves the dom path of the current event.
  *
@@ -186,7 +173,7 @@
 export function getEventPath<T extends PolymerEvent>(e?: T) {
   if (!e) return '';
 
-  let path = e.path;
+  let path = e.composedPath();
   if (!path || !path.length) {
     path = [];
     let el = e.target;
@@ -236,20 +223,58 @@
   return `${prefix}${str.replace(/[^a-zA-Z0-9-_]/g, '_')}`;
 }
 
-// shared API element
-// TODO: Make this a proper service singleton. Move into AppContext?
-let _sharedApiEl: JsApiService;
-
-/**
- * Retrieves the shared API element.
- * We want to keep a single instance of API element instead of
- * creating multiple elements.
- */
-export function getSharedApiEl(): JsApiService {
-  if (!_sharedApiEl) {
-    _sharedApiEl = (document.createElement(
-      'gr-js-api-interface'
-    ) as unknown) as JsApiService;
+// 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;
   }
-  return _sharedApiEl;
+  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;
+}
+
+// Whether the browser is Safari. Used for polyfilling unique browser behavior.
+export function isSafari() {
+  return (
+    /^((?!chrome|android).)*safari/i.test(navigator.userAgent) ||
+    (/iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream)
+  );
+}
+
+export function whenVisible(
+  element: Element,
+  callback: () => void,
+  marginPx = 0
+) {
+  const observer = new IntersectionObserver(
+    (entries: IntersectionObserverEntry[]) => {
+      check(entries.length === 1, 'Expected one intersection observer entry.');
+      const entry = entries[0];
+      if (entry.isIntersecting) {
+        observer.unobserve(entry.target);
+        callback();
+      }
+    },
+    {rootMargin: `${marginPx}px`}
+  );
+  observer.observe(element);
 }
diff --git a/polygerrit-ui/app/utils/dom-util_test.js b/polygerrit-ui/app/utils/dom-util_test.js
index e2d61ed..bcb4505 100644
--- a/polygerrit-ui/app/utils/dom-util_test.js
+++ b/polygerrit-ui/app/utils/dom-util_test.js
@@ -55,13 +55,13 @@
       assert.equal(getEventPath(), '');
       assert.equal(getEventPath(null), '');
       assert.equal(getEventPath(undefined), '');
-      assert.equal(getEventPath({}), '');
+      assert.equal(getEventPath({composedPath: () => []}), '');
     });
 
     test('event with fake path', () => {
-      assert.equal(getEventPath({path: []}), '');
+      assert.equal(getEventPath({composedPath: () => []}), '');
       const dd = document.createElement('dd');
-      assert.equal(getEventPath({path: [dd]}), 'dd');
+      assert.equal(getEventPath({composedPath: () => [dd]}), 'dd');
     });
 
     test('event with fake complicated path', () => {
@@ -72,7 +72,7 @@
       divNode.id = 'test2';
       divNode.className = 'a b c';
       assert.equal(getEventPath(
-          {path: [dd, divNode]}),
+          {composedPath: () => [dd, divNode]}),
       'div#test2.a.b.c>dd#test.a.b'
       );
     });
@@ -88,7 +88,7 @@
       const fakeTarget = document.createElement('SPAN');
       fakeTargetParent1.appendChild(fakeTarget);
       assert.equal(
-          getEventPath({target: fakeTarget}),
+          getEventPath({composedPath: () => {}, target: fakeTarget}),
           'div#test2.a.b.c>dd#test.a.b>span'
       );
     });
diff --git a/polygerrit-ui/app/utils/event-util.ts b/polygerrit-ui/app/utils/event-util.ts
new file mode 100644
index 0000000..cf590e0
--- /dev/null
+++ b/polygerrit-ui/app/utils/event-util.ts
@@ -0,0 +1,134 @@
+/**
+ * @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 {UrlEncodedCommentId} from '../types/common';
+import {FetchRequest} from '../types/types';
+import {DialogChangeEventDetail, TabState} from '../types/events';
+
+export enum EventType {
+  SHOW_ALERT = 'show-alert',
+  PAGE_ERROR = 'page-error',
+  SERVER_ERROR = 'server-error',
+  NETWORK_ERROR = 'network-error',
+  TITLE_CHANGE = 'title-change',
+  THREAD_LIST_MODIFIED = 'thread-list-modified',
+  DIALOG_CHANGE = 'dialog-change',
+  SHOW_PRIMARY_TAB = 'show-primary-tab',
+}
+
+export function fireEvent(target: EventTarget, type: string) {
+  target.dispatchEvent(
+    new CustomEvent(type, {
+      composed: true,
+      bubbles: true,
+    })
+  );
+}
+
+export function fireAlert(target: EventTarget, message: string) {
+  target.dispatchEvent(
+    new CustomEvent(EventType.SHOW_ALERT, {
+      detail: {message},
+      composed: true,
+      bubbles: true,
+    })
+  );
+}
+
+export function firePageError(response?: Response | null) {
+  document.dispatchEvent(
+    new CustomEvent(EventType.PAGE_ERROR, {
+      detail: {response},
+      composed: true,
+      bubbles: true,
+    })
+  );
+}
+
+export function fireServerError(response: Response, request?: FetchRequest) {
+  document.dispatchEvent(
+    new CustomEvent(EventType.SERVER_ERROR, {
+      detail: {response, request},
+      composed: true,
+      bubbles: true,
+    })
+  );
+}
+
+export function fireNetworkError(error: Error) {
+  document.dispatchEvent(
+    new CustomEvent(EventType.NETWORK_ERROR, {
+      detail: {error},
+      composed: true,
+      bubbles: true,
+    })
+  );
+}
+
+export function fireTitleChange(target: EventTarget, title: string) {
+  target.dispatchEvent(
+    new CustomEvent(EventType.TITLE_CHANGE, {
+      detail: {title},
+      composed: true,
+      bubbles: true,
+    })
+  );
+}
+
+// TODO(milutin) - remove once new gr-dialog will do it out of the box
+// This informs gr-app-element to remove footer, header from a11y tree
+export function fireDialogChange(
+  target: EventTarget,
+  detail: DialogChangeEventDetail
+) {
+  target.dispatchEvent(
+    new CustomEvent(EventType.DIALOG_CHANGE, {
+      detail,
+      composed: true,
+      bubbles: true,
+    })
+  );
+}
+
+export function fireThreadListModifiedEvent(
+  target: EventTarget,
+  rootId: UrlEncodedCommentId,
+  path: string
+) {
+  target.dispatchEvent(
+    new CustomEvent(EventType.THREAD_LIST_MODIFIED, {
+      detail: {rootId, path},
+      composed: true,
+      bubbles: true,
+    })
+  );
+}
+
+export function fireShowPrimaryTab(
+  target: EventTarget,
+  tab: string,
+  scrollIntoView?: boolean,
+  tabState?: TabState
+) {
+  target.dispatchEvent(
+    new CustomEvent(EventType.SHOW_PRIMARY_TAB, {
+      detail: {tab, scrollIntoView, tabState},
+      composed: true,
+      bubbles: true,
+    })
+  );
+}
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
index 4313745..4eed0a0 100644
--- a/polygerrit-ui/app/utils/label-util.ts
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -15,7 +15,9 @@
  * limitations under the License.
  */
 import {
+  AccountInfo,
   ApprovalInfo,
+  DetailedLabelInfo,
   isDetailedLabelInfo,
   LabelInfo,
   VotingRangeInfo,
@@ -25,7 +27,7 @@
 export const CODE_REVIEW = 'Code-Review';
 
 export function getVotingRange(label?: LabelInfo): VotingRangeInfo | undefined {
-  if (!label || !isDetailedLabelInfo(label)) return undefined;
+  if (!label || !isDetailedLabelInfo(label) || !label.values) return undefined;
   const values = Object.keys(label.values).map(v => Number(v));
   values.sort((a, b) => a - b);
   if (!values.length) return undefined;
@@ -42,3 +44,18 @@
   const votingRange = getVotingRangeOrDefault(label);
   return label.all.filter(account => account.value === votingRange.max);
 }
+
+export function getApprovalInfo(
+  label: DetailedLabelInfo,
+  account: AccountInfo
+): ApprovalInfo | undefined {
+  return label.all?.filter(x => x._account_id === account._account_id)[0];
+}
+
+export function labelCompare(labelName1: string, labelName2: string) {
+  if (labelName1 === CODE_REVIEW && labelName2 === CODE_REVIEW) return 0;
+  if (labelName1 === CODE_REVIEW) return -1;
+  if (labelName2 === CODE_REVIEW) return 1;
+
+  return labelName1.localeCompare(labelName2);
+}
diff --git a/polygerrit-ui/app/utils/label-util_test.js b/polygerrit-ui/app/utils/label-util_test.js
index d6f7b3e..f9a30df 100644
--- a/polygerrit-ui/app/utils/label-util_test.js
+++ b/polygerrit-ui/app/utils/label-util_test.js
@@ -20,6 +20,8 @@
   getVotingRange,
   getVotingRangeOrDefault,
   getMaxAccounts,
+  getApprovalInfo,
+  labelCompare,
 } from './label-util.js';
 
 const VALUES_1 = {
@@ -87,4 +89,36 @@
     assert.isEmpty(getMaxAccounts({}));
     assert.isEmpty(getMaxAccounts({values: VALUES_2}));
   });
+
+  test('getApprovalInfo', () => {
+    const myAccountInfo = {_account_id: 314};
+    const myApprovalInfo = {value: 2, _account_id: 314};
+    const label = {
+      values: VALUES_2,
+      all: [myApprovalInfo, {value: 1, _account_id: 777}],
+    };
+    assert.equal(
+        getApprovalInfo(label, myAccountInfo),
+        myApprovalInfo
+    );
+  });
+
+  test('getApprovalInfo no approval for user', () => {
+    const myAccountInfo = {_account_id: 123};
+    const label = {
+      values: VALUES_2,
+      all: [
+        {value: 2, _account_id: 314},
+        {value: 1, _account_id: 777},
+      ],
+    };
+    assert.isUndefined(getApprovalInfo(label, myAccountInfo));
+  });
+
+  test('labelCompare', () => {
+    let sorted = ['c', 'b', 'a'].sort(labelCompare);
+    assert.sameOrderedMembers(sorted, ['a', 'b', 'c']);
+    sorted = ['b', 'a', 'Code-Review'].sort(labelCompare);
+    assert.sameOrderedMembers(sorted, ['Code-Review', 'a', 'b']);
+  });
 });
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index 8974af8..af56798 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -3,14 +3,12 @@
   ChangeInfo,
   PatchSetNum,
   EditPatchSetNum,
-  BrandType,
   ParentPatchSetNum,
+  PatchSetNumber,
 } from '../types/common';
-import {RestApiService} from '../services/services/gr-rest-api/gr-rest-api';
-import {
-  EditRevisionInfo,
-  ParsedChangeInfo,
-} from '../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
+import {EditRevisionInfo, ParsedChangeInfo} from '../types/types';
+import {check} from './common-util';
 
 /**
  * @license
@@ -38,12 +36,7 @@
 // Tags identifying ChangeMessages that move change out of WIP state.
 const READY_TAGS = ['autogenerated:gerrit:setReadyForReview'];
 
-// TODO(TS): Replace usages of these constants by
-// EditPatchSetNum and ParentPatchSetNum in common.ts.
-export const SPECIAL_PATCH_SET_NUM = {
-  EDIT: 'edit',
-  PARENT: 'PARENT',
-};
+export const CURRENT = 'current';
 
 export interface PatchSet {
   num: PatchSetNum;
@@ -58,19 +51,6 @@
 }
 
 /**
- * As patchNum can be either a string (e.g. 'edit', 'PARENT') OR a number,
- * this function checks for patchNum equality.
- *
- */
-export function patchNumEquals(a?: PatchSetNum, b?: PatchSetNum) {
-  if (a === undefined) {
-    return a === b;
-  }
-  // TODO(TS): replace with a===b when the whole code is converted to ts
-  return `${a}` === `${b}`;
-}
-
-/**
  * Whether the given patch is a numbered parent of a merge (i.e. a negative
  * number).
  */
@@ -78,6 +58,14 @@
   return `${n}`[0] === '-';
 }
 
+/**
+ * Whether the given patch is a parent, either a regular parent or a merge
+ * parent.
+ */
+export function isAParent(n: PatchSetNum) {
+  return n === ParentPatchSetNum || isMergeParent(n);
+}
+
 export function isPatchSetNum(patchset: string) {
   if (!isNaN(Number(patchset))) return true;
   return patchset === EditPatchSetNum || patchset === ParentPatchSetNum;
@@ -95,9 +83,7 @@
   return patchset as PatchSetNum;
 }
 
-export function isNumber(
-  psn: PatchSetNum
-): psn is BrandType<number, '_patchSet'> {
+export function isNumber(psn: PatchSetNum): psn is PatchSetNumber {
   return typeof psn === 'number';
 }
 
@@ -112,7 +98,7 @@
   patchNum: PatchSetNum
 ) {
   for (const rev of revisions) {
-    if (patchNumEquals(rev._number, patchNum)) {
+    if (rev._number === patchNum) {
       return rev;
     }
   }
@@ -196,11 +182,9 @@
  *     above
  */
 export function computeAllPatchSets(
-  change: ChangeInfo | ParsedChangeInfo
+  change: ChangeInfo | ParsedChangeInfo | undefined
 ): PatchSet[] {
-  if (!change) {
-    return [];
-  }
+  if (!change) return [];
 
   let patchNums: PatchSet[] = [];
   if (change.revisions && Object.keys(change.revisions).length) {
@@ -238,7 +222,7 @@
     return patchNums;
   }
   // TODO(TS): replace with Map<PatchNum, boolean>
-  const psWip: Map<string, boolean> = new Map();
+  const psWip: Map<string, boolean> = new Map<string, boolean>();
   let wip = !!change.work_in_progress;
   for (let i = 0; i < change.messages.length; i++) {
     const msg = change.messages[i];
@@ -265,14 +249,30 @@
 
 export function computeLatestPatchNum(
   allPatchSets?: PatchSet[]
-): PatchSetNum | undefined {
+): PatchSetNumber | undefined {
   if (!allPatchSets || !allPatchSets.length) {
     return undefined;
   }
-  if (allPatchSets[0].num === EditPatchSetNum) {
-    return allPatchSets[1].num;
+  let latest = allPatchSets[0].num;
+  if (latest === EditPatchSetNum) {
+    latest = allPatchSets[1].num;
   }
-  return allPatchSets[0].num;
+  check(isNumber(latest), 'Latest patchset cannot be EDIT or PARENT.');
+  return latest;
+}
+
+export function computePredecessor(
+  patchset?: PatchSetNum
+): PatchSetNum | undefined {
+  if (
+    !patchset ||
+    patchset === ParentPatchSetNum ||
+    patchset === EditPatchSetNum
+  ) {
+    return undefined;
+  }
+  if (patchset === 1) return ParentPatchSetNum;
+  return (Number(patchset) - 1) as PatchSetNum;
 }
 
 export function hasEditBasedOnCurrentPatchSet(allPatchSets: PatchSet[]) {
@@ -316,7 +316,9 @@
       isLatest: actualLatest <= knownLatest,
       newStatus: change.status !== detail.status ? detail.status : null,
       newMessages:
-        (change.messages || []).length < (detail.messages || []).length,
+        (change.messages || []).length < (detail.messages || []).length
+          ? detail.messages![detail.messages!.length - 1]
+          : undefined,
     };
   });
 }
diff --git a/polygerrit-ui/app/utils/patch-set-util_test.js b/polygerrit-ui/app/utils/patch-set-util_test.js
index 29cc370..eefdcda 100644
--- a/polygerrit-ui/app/utils/patch-set-util_test.js
+++ b/polygerrit-ui/app/utils/patch-set-util_test.js
@@ -21,7 +21,7 @@
   fetchChangeUpdates, findEditParentPatchNum, findEditParentRevision,
   getParentIndex, getRevisionByPatchNum,
   isMergeParent,
-  patchNumEquals, sortRevisions,
+  sortRevisions,
 } from './patch-set-util.js';
 
 suite('gr-patch-set-util tests', () => {
@@ -31,9 +31,9 @@
       {_number: 1},
       {_number: 2},
     ];
-    assert.deepEqual(getRevisionByPatchNum(revisions, '1'), revisions[1]);
+    assert.deepEqual(getRevisionByPatchNum(revisions, 1), revisions[1]);
     assert.deepEqual(getRevisionByPatchNum(revisions, 2), revisions[2]);
-    assert.equal(getRevisionByPatchNum(revisions, '3'), undefined);
+    assert.equal(getRevisionByPatchNum(revisions, 3), undefined);
   });
 
   test('fetchChangeUpdates on latest', done => {
@@ -54,7 +54,7 @@
         .then(result => {
           assert.isTrue(result.isLatest);
           assert.isNotOk(result.newStatus);
-          assert.isFalse(result.newMessages);
+          assert.isNotOk(result.newMessages);
           done();
         });
   });
@@ -86,7 +86,7 @@
         .then(result => {
           assert.isFalse(result.isLatest);
           assert.isNotOk(result.newStatus);
-          assert.isFalse(result.newMessages);
+          assert.isNotOk(result.newMessages);
           done();
         });
   });
@@ -117,7 +117,7 @@
         .then(result => {
           assert.isTrue(result.isLatest);
           assert.equal(result.newStatus, 'MERGED');
-          assert.isFalse(result.newMessages);
+          assert.isNotOk(result.newMessages);
           done();
         });
   });
@@ -148,7 +148,7 @@
         .then(result => {
           assert.isTrue(result.isLatest);
           assert.isNotOk(result.newStatus);
-          assert.isTrue(result.newMessages);
+          assert.deepEqual(result.newMessages, {message: 'blah blah'});
           done();
         });
   });
@@ -231,17 +231,6 @@
         .assertWip(6, true); // PS6 was uploaded with WIP option
   });
 
-  test('patchNumEquals', () => {
-    assert.isFalse(patchNumEquals('edit', 'PARENT'));
-    assert.isFalse(patchNumEquals('edit', NaN));
-    assert.isFalse(patchNumEquals(1, '2'));
-
-    assert.isTrue(patchNumEquals(1, '1'));
-    assert.isTrue(patchNumEquals(1, 1));
-    assert.isTrue(patchNumEquals('edit', 'edit'));
-    assert.isTrue(patchNumEquals('PARENT', 'PARENT'));
-  });
-
   test('isMergeParent', () => {
     assert.isFalse(isMergeParent(1));
     assert.isFalse(isMergeParent(4321));
diff --git a/polygerrit-ui/app/utils/string-util.ts b/polygerrit-ui/app/utils/string-util.ts
new file mode 100644
index 0000000..1b400cf
--- /dev/null
+++ b/polygerrit-ui/app/utils/string-util.ts
@@ -0,0 +1,28 @@
+/**
+ * @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.
+ */
+
+/**
+ * Returns a count plus string that is pluralized when necessary.
+ */
+export function pluralize(count: number, noun: string): string {
+  if (count === 0) return '';
+  return `${count} ${noun}` + (count > 1 ? 's' : '');
+}
+
+export function addQuotesWhen(string: string, cond: boolean): string {
+  return cond ? `"${string}"` : string;
+}
diff --git a/polygerrit-ui/app/utils/string-util_test.ts b/polygerrit-ui/app/utils/string-util_test.ts
new file mode 100644
index 0000000..9297d90
--- /dev/null
+++ b/polygerrit-ui/app/utils/string-util_test.ts
@@ -0,0 +1,28 @@
+/**
+ * @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 '../test/common-test-setup-karma.js';
+import {pluralize} from './string-util.js';
+
+suite('formatter util tests', () => {
+  test('pluralize', () => {
+    const noun = 'comment';
+    assert.equal(pluralize(0, noun), '');
+    assert.equal(pluralize(1, noun), '1 comment');
+    assert.equal(pluralize(2, noun), '2 comments');
+  });
+});
diff --git a/polygerrit-ui/app/utils/url-util.ts b/polygerrit-ui/app/utils/url-util.ts
index 0c6fabc..f977ab6 100644
--- a/polygerrit-ui/app/utils/url-util.ts
+++ b/polygerrit-ui/app/utils/url-util.ts
@@ -1,5 +1,5 @@
 import {ServerInfo} from '../types/common';
-import {RestApiService} from '../services/services/gr-rest-api/gr-rest-api';
+import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
 
 /**
  * @license
diff --git a/polygerrit-ui/app/utils/url-util_test.js b/polygerrit-ui/app/utils/url-util_test.js
index 0658be3..b1b17f4 100644
--- a/polygerrit-ui/app/utils/url-util_test.js
+++ b/polygerrit-ui/app/utils/url-util_test.js
@@ -25,15 +25,15 @@
 
 suite('url-util tests', () => {
   suite('getBaseUrl tests', () => {
-    let originialCanonicalPath;
+    let originalCanonicalPath;
 
     suiteSetup(() => {
-      originialCanonicalPath = window.CANONICAL_PATH;
+      originalCanonicalPath = window.CANONICAL_PATH;
       window.CANONICAL_PATH = '/r';
     });
 
     suiteTeardown(() => {
-      window.CANONICAL_PATH = originialCanonicalPath;
+      window.CANONICAL_PATH = originalCanonicalPath;
     });
 
     test('getBaseUrl', () => {
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index eda02aa..ec3b7a0 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -324,6 +324,11 @@
   dependencies:
     "@webcomponents/shadycss" "^1.9.1"
 
+"@types/resize-observer-browser@^0.1.5":
+  version "0.1.5"
+  resolved "https://registry.yarnpkg.com/@types/resize-observer-browser/-/resize-observer-browser-0.1.5.tgz#36d897708172ac2380cd486da7a3daf1161c1e23"
+  integrity sha512-8k/67Z95Goa6Lznuykxkfhq9YU3l1Qe6LNZmwde1u7802a3x8v44oq0j91DICclxatTr0rNnhXx7+VTIetSrSQ==
+
 "@webcomponents/shadycss@^1.9.1", "@webcomponents/shadycss@^1.9.2":
   version "1.9.4"
   resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.9.4.tgz#4f9d8ea1526bab084c60b53d4854dc39fdb2bb48"
@@ -347,6 +352,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"
@@ -372,7 +389,19 @@
     "@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"
+
 shadow-selection-polyfill@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/shadow-selection-polyfill/-/shadow-selection-polyfill-1.1.0.tgz#87eee5c3cd9c7296f9fec083ba6f4910b1fa6686"
   integrity sha512-ntz8P6DLEFpx7gikeXZ4gSi3APE2D+BP0rKnnaBzED+Lm8je8nkNcayy6kGWPEDWMFbtm+Yvd1ONFaXcsVWn2w==
+
+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/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/proto/BUILD b/proto/BUILD
index 57be265..7aa761d 100644
--- a/proto/BUILD
+++ b/proto/BUILD
@@ -4,6 +4,7 @@
 proto_library(
     name = "cache_proto",
     srcs = ["cache.proto"],
+    deps = [":entities_proto"],
 )
 
 java_proto_library(
diff --git a/proto/cache.proto b/proto/cache.proto
index 7924cbd..a610e49 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -18,7 +18,9 @@
 
 option java_package = "com.google.gerrit.server.cache.proto";
 
-// Serialized form of com.google.gerrit.server.change.CHangeKindCacheImpl.Key.
+import "proto/entities.proto";
+
+// Serialized form of com.google.gerrit.server.change.ChangeKindCacheImpl.Key.
 // Next ID: 4
 message ChangeKindKeyProto {
   bytes prior = 1;
@@ -76,7 +78,7 @@
 // Instead, we just take the tedious yet simple approach of having a "has_foo"
 // field for each nullable field "foo", indicating whether or not foo is null.
 //
-// Next ID: 24
+// Next ID: 27
 message ChangeNotesStateProto {
   // Effectively required, even though the corresponding ChangeNotesState field
   // is optional, since the field is only absent when NoteDb is disabled, in
@@ -140,11 +142,9 @@
 
   repeated string hashtag = 5;
 
-  // Raw PatchSet proto as produced by PatchSetProtoConverter.
-  repeated bytes patch_set = 6;
+  repeated devtools.gerritcodereview.PatchSet patch_set = 6;
 
-  // Raw PatchSetApproval proto as produced by PatchSetApprovalProtoConverter.
-  repeated bytes approval = 7;
+  repeated devtools.gerritcodereview.PatchSetApproval approval = 7;
 
   // Next ID: 4
   message ReviewerSetEntryProto {
@@ -184,8 +184,7 @@
   // com.google.gerrit.server.index.change.ChangeField.StoredSubmitRecord.
   repeated string submit_record = 14;
 
-  // Raw ChangeMessage proto as produced by ChangeMessageProtoConverter.
-  repeated bytes change_message = 15;
+  repeated devtools.gerritcodereview.ChangeMessage change_message = 15;
 
   // JSON produced from com.google.gerrit.entities.Comment.
   repeated string published_comment = 16;
@@ -218,7 +217,15 @@
     string operation = 3;
     string reason = 4;
   }
+  // Only includes the most recent attention set update for each user.
   repeated AttentionSetUpdateProto attention_set_update = 23;
+
+  // Includes all attention set updates.
+  repeated AttentionSetUpdateProto all_attention_set_update = 24;
+
+  // Epoch millis.
+  int64 merged_on_millis = 25;
+  bool has_merged_on = 26;
 }
 
 // Serialized form of com.google.gerrit.server.query.change.ConflictKey
@@ -435,6 +442,7 @@
   int32 max_positive = 16;
   bool can_override = 17;
   repeated string ref_patterns = 18;
+  bool copy_all_scores_if_list_of_files_did_not_change = 19;
 }
 
 // Serialized form of com.google.gerrit.server.project.ConfiguredMimeTypes.
@@ -500,3 +508,144 @@
   bytes global_config_revision = 3; // Hash of All-Projects-projects.config. This
                                     // will only be populated for All-Projects.
 }
+
+// Serialized form of com.google.gerrit.server.comment.CommentContextCacheImpl.Key
+// Next ID: 7
+message CommentContextKeyProto {
+  string project = 1;
+  string change_id = 2;
+  int32 patchset = 3;
+  string commentId = 4;
+
+  // hashed with the murmur3_128 hash function
+  string path_hash = 5;
+
+  int32 context_padding = 6;
+}
+
+// Serialized form of a list of com.google.gerrit.extensions.common.ContextLineInfo
+// Next ID: 2
+message AllCommentContextProto {
+  message CommentContextProto {
+    int32 line_number = 1;
+    string context_line = 2;
+  }
+  repeated CommentContextProto context = 1;
+}
+
+// Serialized key for
+// com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheKey
+// Next ID: 5
+message GitModifiedFilesKeyProto {
+  string project = 1;
+  bytes a_tree = 2; // SHA-1 hash of the left git tree ID in the diff
+  bytes b_tree = 3; // SHA-1 hash of the right git tree ID in the diff
+  int32 rename_score = 4;
+}
+
+// Serialized key for
+// com.google.gerrit.server.patch.diff.ModifiedFilesCacheKey
+// Next ID: 5
+message ModifiedFilesKeyProto {
+  string project = 1;
+  bytes a_commit = 2; // SHA-1 hash of the left commit ID in the diff
+  bytes b_commit = 3; // SHA-1 hash of the right commit ID in the diff
+  int32 rename_score = 4;
+}
+
+// Serialized form of com.google.gerrit.server.patch.gitdiff.ModifiedFile
+// Next ID: 4
+message ModifiedFileProto {
+  string change_type = 1; // ENUM as string
+  string old_path = 2;
+  string new_path = 3;
+}
+
+// Serialized form of a collection of
+// com.google.gerrit.server.patch.gitdiff.ModifiedFile
+// Next ID: 2
+message ModifiedFilesProto {
+  repeated ModifiedFileProto modifiedFile = 1;
+}
+
+// Serialized form of a collection of
+// com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl.Key
+// Next ID: 8
+message GitFileDiffKeyProto {
+  string project = 1;
+  bytes a_tree = 2;
+  bytes b_tree = 3;
+  string file_path = 4;
+  int32 rename_score = 5;
+  string diff_algorithm = 6; // ENUM as string
+  string whitepsace = 7; // ENUM as string
+}
+
+// Serialized form of com.google.gerrit.server.patch.gitfilediff.GitFileDiff
+// Next ID: 11
+message GitFileDiffProto {
+  message Edit {
+    int32 begin_a = 1;
+    int32 end_a = 2;
+    int32 begin_b = 3;
+    int32 end_b = 4;
+  }
+  repeated Edit edits = 1;
+  string file_header = 2;
+  string old_path = 3;
+  string new_path = 4;
+  bytes old_id = 5;
+  bytes new_id = 6;
+  string old_mode = 7; // ENUM as string
+  string new_mode = 8; // ENUM as string
+  string change_type = 9; // ENUM as string
+  string patch_type = 10; // ENUM as string
+}
+
+// Serialized form of
+// com.google.gerrit.server.patch.fileDiff.FileDiffCacheKey
+// Next ID: 8
+message FileDiffKeyProto {
+  string project = 1;
+  bytes old_commit = 2;
+  bytes new_commit = 3;
+  string file_path = 4;
+  int32 rename_score = 5;
+  string diff_algorithm = 6;
+  string whitespace = 7;
+}
+
+// Serialized form of
+// com.google.gerrit.server.patch.filediff.FileDiffOutput
+// Next ID: 12
+message FileDiffOutputProto {
+  // Next ID: 5
+  message Edit {
+    int32 begin_a = 1;
+    int32 end_a = 2;
+    int32 begin_b = 3;
+    int32 end_b = 4;
+  }
+  // Serialized form  of com.google.gerrit.server.patch.filediff.TaggedEdit
+  // Next ID: 3
+  message TaggedEdit {
+    Edit edit = 1;
+    bool due_to_rebase = 2;
+  }
+  // Next ID: 3
+  message ComparisonType {
+    int32 parent_num = 1;
+    bool auto_merge = 2;
+  }
+  string old_path = 1;
+  string new_path = 2;
+  string change_type = 3;
+  string patch_type = 4;
+  repeated string header_lines = 5;
+  int64 size = 6;
+  int64 size_delta = 7;
+  repeated TaggedEdit edits = 8;
+  bytes old_commit = 9;
+  bytes new_commit = 10;
+  ComparisonType comparison_type = 11;
+}
diff --git a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index 000f4e2..93584c6 100644
--- a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -16,7 +16,7 @@
 
 {namespace com.google.gerrit.httpd.raw}
 
-{template .Index}
+{template Index}
   {@param canonicalPath: ?}
   {@param staticResourcePath: ?}
   {@param gerritInitialData: /** {string} map of REST endpoint to response for startup. */ ?}
@@ -171,5 +171,5 @@
 
   // Load gr-app.js after <gr-app ...> tag because gr-router expects that
   // <gr-app ...> already exists in the document when script is executed.
-  <script src="{$staticResourcePath}/elements/gr-app.js"></script>{\n}
+  <script src="{$staticResourcePath}/elements/gr-app.js" crossorigin="anonymous"></script>{\n}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/Abandoned.soy b/resources/com/google/gerrit/server/mail/Abandoned.soy
index d5aac0e..b57f00a 100644
--- a/resources/com/google/gerrit/server/mail/Abandoned.soy
+++ b/resources/com/google/gerrit/server/mail/Abandoned.soy
@@ -20,7 +20,7 @@
  * The .Abandoned template will determine the contents of the email related to a
  * change being abandoned.
  */
-{template .Abandoned kind="text"}
+{template Abandoned kind="text"}
   {@param change: ?}
   {@param coverLetter: ?}
   {@param email: ?}
diff --git a/resources/com/google/gerrit/server/mail/AbandonedHtml.soy b/resources/com/google/gerrit/server/mail/AbandonedHtml.soy
index 9ad996e..9f80241 100644
--- a/resources/com/google/gerrit/server/mail/AbandonedHtml.soy
+++ b/resources/com/google/gerrit/server/mail/AbandonedHtml.soy
@@ -16,7 +16,9 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-{template .AbandonedHtml}
+import * as mailTemplate from 'com/google/gerrit/server/mail/Private.soy';
+
+{template AbandonedHtml}
   {@param coverLetter: ?}
   {@param email: ?}
   {@param fromName: ?}
@@ -26,7 +28,7 @@
 
   {if $email.changeUrl}
     <p>
-      {call .ViewChangeButton data="all" /}
+      {call mailTemplate.ViewChangeButton data="all" /}
     </p>
   {/if}
 
diff --git a/resources/com/google/gerrit/server/mail/AddKey.soy b/resources/com/google/gerrit/server/mail/AddKey.soy
index 8b609cf..c77ab56 100644
--- a/resources/com/google/gerrit/server/mail/AddKey.soy
+++ b/resources/com/google/gerrit/server/mail/AddKey.soy
@@ -16,11 +16,13 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
+import * as mailTemplate from 'com/google/gerrit/server/mail/NoReplyFooter.soy';
+
 /**
  * The .AddKey template will determine the contents of the email related to
  * adding a new SSH or GPG key to an account.
  */
-{template .AddKey kind="text"}
+{template AddKey kind="text"}
   {@param email: ?}
   One or more new {$email.keyType} keys have been added to Gerrit Code Review at
   {sp}{$email.gerritHost}:
@@ -64,5 +66,5 @@
   browser window instead.
 
   {\n}
-  {call .NoReplyFooter /}
+  {call mailTemplate.NoReplyFooter /}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/AddKeyHtml.soy b/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
index ed4f435..3987684 100644
--- a/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
+++ b/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
@@ -16,7 +16,9 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-{template .AddKeyHtml}
+import * as mailTemplate from 'com/google/gerrit/server/mail/NoReplyFooterHtml.soy';
+
+{template AddKeyHtml}
   {@param email: ?}
   <p>
     One or more new {$email.keyType} keys have been added to Gerrit Code Review
@@ -57,5 +59,5 @@
     {/if}.
   </p>
 
-  {call .NoReplyFooterHtml /}
+  {call mailTemplate.NoReplyFooterHtml /}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/AddToAttentionSet.soy b/resources/com/google/gerrit/server/mail/AddToAttentionSet.soy
index 5ea41b2..64c1ad3 100644
--- a/resources/com/google/gerrit/server/mail/AddToAttentionSet.soy
+++ b/resources/com/google/gerrit/server/mail/AddToAttentionSet.soy
@@ -20,7 +20,7 @@
  * The .AddToAttentionSet template will determine the contents of the email related to a
  * user being added to the attention set.
  */
-{template .AddToAttentionSet kind="text"}
+{template AddToAttentionSet kind="text"}
   {@param change: ?}
   {@param coverLetter: ?}
   {@param email: ?}
diff --git a/resources/com/google/gerrit/server/mail/AddToAttentionSetHtml.soy b/resources/com/google/gerrit/server/mail/AddToAttentionSetHtml.soy
index bac180a..04d759e 100644
--- a/resources/com/google/gerrit/server/mail/AddToAttentionSetHtml.soy
+++ b/resources/com/google/gerrit/server/mail/AddToAttentionSetHtml.soy
@@ -16,7 +16,9 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-{template .AddToAttentionSetHtml}
+import * as mailTemplate from 'com/google/gerrit/server/mail/Private.soy';
+
+{template AddToAttentionSetHtml}
   {@param coverLetter: ?}
   {@param email: ?}
   {@param fromName: ?}
@@ -33,11 +35,11 @@
 
   {if $email.changeUrl}
     <p>
-      {call .ViewChangeButton data="all" /}
+      {call mailTemplate.ViewChangeButton data="all" /}
     </p>
   {/if}
 
   {if $coverLetter}
     <div style="white-space:pre-wrap">{$coverLetter}</div>
   {/if}
-{/template}
\ No newline at end of file
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/ChangeFooter.soy b/resources/com/google/gerrit/server/mail/ChangeFooter.soy
index a8170ca..236b171 100644
--- a/resources/com/google/gerrit/server/mail/ChangeFooter.soy
+++ b/resources/com/google/gerrit/server/mail/ChangeFooter.soy
@@ -20,7 +20,7 @@
  * The .ChangeFooter template will determine the contents of the footer text
  * that will be appended to ALL emails related to changes.
  */
-{template .ChangeFooter kind="text"}
+{template ChangeFooter kind="text"}
   {@param email: ?}
   --{sp}
   {\n}
diff --git a/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy b/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy
index b619c53..28442ee 100644
--- a/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy
+++ b/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy
@@ -16,7 +16,7 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-{template .ChangeFooterHtml}
+{template ChangeFooterHtml}
   {@param change: ?}
   {@param email: ?}
   {if $email.changeUrl or $email.settingsUrl}
diff --git a/resources/com/google/gerrit/server/mail/ChangeHeader.soy b/resources/com/google/gerrit/server/mail/ChangeHeader.soy
index fde69f1..7a2da65 100644
--- a/resources/com/google/gerrit/server/mail/ChangeHeader.soy
+++ b/resources/com/google/gerrit/server/mail/ChangeHeader.soy
@@ -16,7 +16,7 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-{template .ChangeHeader kind="text"}
+{template ChangeHeader kind="text"}
   {@param attentionSet: ?}
   {if $attentionSet}
     Attention is currently required from:{sp}
diff --git a/resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy b/resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy
index ea12455..a1bcd8f 100644
--- a/resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy
+++ b/resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy
@@ -17,7 +17,7 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-{template .ChangeHeaderHtml}
+{template ChangeHeaderHtml}
   {@param attentionSet: ?}
   {if $attentionSet}
     <p> Attention is currently required from:{sp}
diff --git a/resources/com/google/gerrit/server/mail/ChangeSubject.soy b/resources/com/google/gerrit/server/mail/ChangeSubject.soy
index 7fcd213..12422ed 100644
--- a/resources/com/google/gerrit/server/mail/ChangeSubject.soy
+++ b/resources/com/google/gerrit/server/mail/ChangeSubject.soy
@@ -20,7 +20,7 @@
  * The .ChangeSubject template will determine the contents of the email subject
  * line for ALL emails related to changes.
  */
-{template .ChangeSubject kind="text"}
+{template ChangeSubject kind="text"}
   {@param branch: ?}
   {@param change: ?}
   {@param shortProjectName: ?}
diff --git a/resources/com/google/gerrit/server/mail/Comment.soy b/resources/com/google/gerrit/server/mail/Comment.soy
index 893ef6f..973b369 100644
--- a/resources/com/google/gerrit/server/mail/Comment.soy
+++ b/resources/com/google/gerrit/server/mail/Comment.soy
@@ -20,7 +20,7 @@
  * The .Comment template will determine the contents of the email related to a
  * user submitting comments on changes.
  */
-{template .Comment kind="text"}
+{template Comment kind="text"}
   {@param change: ?}
   {@param coverLetter: ?}
   {@param email: ?}
diff --git a/resources/com/google/gerrit/server/mail/CommentFooter.soy b/resources/com/google/gerrit/server/mail/CommentFooter.soy
index 3998438..3c111f7 100644
--- a/resources/com/google/gerrit/server/mail/CommentFooter.soy
+++ b/resources/com/google/gerrit/server/mail/CommentFooter.soy
@@ -21,5 +21,5 @@
  * that will be appended to emails related to a user submitting comments on
  * changes.
  */
-{template .CommentFooter kind="text"}
+{template CommentFooter kind="text"}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy b/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy
index 033c1b1..ce8a933 100644
--- a/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy
+++ b/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy
@@ -16,5 +16,5 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-{template .CommentFooterHtml}
+{template CommentFooterHtml}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/CommentHtml.soy b/resources/com/google/gerrit/server/mail/CommentHtml.soy
index 21fee18..b3924c3 100644
--- a/resources/com/google/gerrit/server/mail/CommentHtml.soy
+++ b/resources/com/google/gerrit/server/mail/CommentHtml.soy
@@ -16,7 +16,9 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-{template .CommentHtml}
+import * as mailTemplate from 'com/google/gerrit/server/mail/Private.soy';
+
+{template CommentHtml}
   {@param commentFiles: ?}
   {@param commentCount: ?}
   {@param email: ?}
@@ -71,7 +73,9 @@
   {/let}
 
   {if $patchSetCommentBlocks}
-    {call .WikiFormat}{param content: $patchSetCommentBlocks /}{/call}
+    {call mailTemplate.WikiFormat}
+      {param content: $patchSetCommentBlocks /}
+    {/call}
   {/if}
 
   {if length($labels) > 0}
@@ -97,7 +101,7 @@
 
   {if $email.changeUrl}
     <p>
-      {call .ViewChangeButton data="all" /}
+      {call mailTemplate.ViewChangeButton data="all" /}
     </p>
   {/if}
 
@@ -146,11 +150,13 @@
               {if length($comment.lines) > 1}
                 <p>
                   <blockquote style="{$blockquoteStyle}">
-                    {call .Pre}{param content kind="html"}
-                      {for $line in $comment.lines}
-                        {$line}{\n}
-                      {/for}
-                    {/param}{/call}
+                    {call mailTemplate.Pre}
+                      {param content kind="html"}
+                        {for $line in $comment.lines}
+                          {$line}{\n}
+                        {/for}
+                      {/param}
+                    {/call}
                   </blockquote>
                 </p>
               {/if}
@@ -163,7 +169,9 @@
                 </p>
               {/if}
 
-              {call .WikiFormat}{param content: $comment.messageBlocks /}{/call}
+              {call mailTemplate.WikiFormat}
+                {param content: $comment.messageBlocks /}
+              {/call}
             </li>
           {/for}
         </ul>
diff --git a/resources/com/google/gerrit/server/mail/DeleteKey.soy b/resources/com/google/gerrit/server/mail/DeleteKey.soy
index 30548c8..ffc12dc 100644
--- a/resources/com/google/gerrit/server/mail/DeleteKey.soy
+++ b/resources/com/google/gerrit/server/mail/DeleteKey.soy
@@ -20,7 +20,7 @@
  * The .DeleteKey template will determine the contents of the email related to
  * deleting a SSH or GPG key.
  */
-{template .DeleteKey kind="text"}
+{template DeleteKey kind="text"}
   {@param email: ?}
   One or more {$email.keyType} keys have been deleted on Gerrit Code Review at
   {sp}{$email.gerritHost}:
diff --git a/resources/com/google/gerrit/server/mail/DeleteKeyHtml.soy b/resources/com/google/gerrit/server/mail/DeleteKeyHtml.soy
index 1ab3955..4ce5246 100644
--- a/resources/com/google/gerrit/server/mail/DeleteKeyHtml.soy
+++ b/resources/com/google/gerrit/server/mail/DeleteKeyHtml.soy
@@ -16,7 +16,7 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-{template .DeleteKeyHtml}
+{template DeleteKeyHtml}
   {@param email: ?}
   <p>
     One or more {$email.keyType} keys have been deleted on Gerrit Code Review
diff --git a/resources/com/google/gerrit/server/mail/DeleteReviewer.soy b/resources/com/google/gerrit/server/mail/DeleteReviewer.soy
index 3310249..a9ba802 100644
--- a/resources/com/google/gerrit/server/mail/DeleteReviewer.soy
+++ b/resources/com/google/gerrit/server/mail/DeleteReviewer.soy
@@ -20,7 +20,7 @@
  * The .DeleteReviewer template will determine the contents of the email related
  * to removal of a reviewer (and the reviewer's votes) from reviews.
  */
-{template .DeleteReviewer kind="text"}
+{template DeleteReviewer kind="text"}
   {@param change: ?}
   {@param coverLetter: ?}
   {@param email: ?}
diff --git a/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy b/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy
index 54720fe..685ca4c 100644
--- a/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy
+++ b/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy
@@ -16,7 +16,9 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-{template .DeleteReviewerHtml}
+import * as mailTemplate from 'com/google/gerrit/server/mail/Private.soy';
+
+{template DeleteReviewerHtml}
   {@param email: ?}
   {@param fromName: ?}
   <p>
@@ -35,7 +37,7 @@
 
   {if $email.changeUrl}
     <p>
-      {call .ViewChangeButton data="all" /}
+      {call mailTemplate.ViewChangeButton data="all" /}
     </p>
   {/if}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/DeleteVote.soy b/resources/com/google/gerrit/server/mail/DeleteVote.soy
index 0ee5454..74790f7 100644
--- a/resources/com/google/gerrit/server/mail/DeleteVote.soy
+++ b/resources/com/google/gerrit/server/mail/DeleteVote.soy
@@ -20,7 +20,7 @@
  * The .DeleteVote template will determine the contents of the email related
  * to removing votes on changes.
  */
-{template .DeleteVote kind="text"}
+{template DeleteVote kind="text"}
   {@param change: ?}
   {@param coverLetter: ?}
   {@param email: ?}
diff --git a/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy b/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy
index 3a82927..dd3b423 100644
--- a/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy
+++ b/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy
@@ -16,7 +16,9 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-{template .DeleteVoteHtml}
+import * as mailTemplate from 'com/google/gerrit/server/mail/Private.soy';
+
+{template DeleteVoteHtml}
   {@param coverLetter: ?}
   {@param email: ?}
   {@param fromName: ?}
@@ -26,7 +28,7 @@
 
   {if $email.changeUrl}
     <p>
-      {call .ViewChangeButton data="all" /}
+      {call mailTemplate.ViewChangeButton data="all" /}
     </p>
   {/if}
 
diff --git a/resources/com/google/gerrit/server/mail/Footer.soy b/resources/com/google/gerrit/server/mail/Footer.soy
index 7483cd9..6ce0d3b 100644
--- a/resources/com/google/gerrit/server/mail/Footer.soy
+++ b/resources/com/google/gerrit/server/mail/Footer.soy
@@ -21,7 +21,7 @@
  * appended to the end of all outgoing emails after the ChangeFooter and
  * CommentFooter.
  */
-{template .Footer kind="text"}
+{template Footer kind="text"}
   {@param footers: ?}
   {for $footer in $footers}
     {$footer}{\n}
diff --git a/resources/com/google/gerrit/server/mail/FooterHtml.soy b/resources/com/google/gerrit/server/mail/FooterHtml.soy
index ce934d3..c89dea7 100644
--- a/resources/com/google/gerrit/server/mail/FooterHtml.soy
+++ b/resources/com/google/gerrit/server/mail/FooterHtml.soy
@@ -16,7 +16,7 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-{template .FooterHtml}
+{template FooterHtml}
   {@param footers: ?}
   {\n}
   {\n}
diff --git a/resources/com/google/gerrit/server/mail/HttpPasswordUpdate.soy b/resources/com/google/gerrit/server/mail/HttpPasswordUpdate.soy
index 38e679e..08daa932 100644
--- a/resources/com/google/gerrit/server/mail/HttpPasswordUpdate.soy
+++ b/resources/com/google/gerrit/server/mail/HttpPasswordUpdate.soy
@@ -20,7 +20,7 @@
  * The .HttpPasswordUpdate template will determine the contents of the email related to
  * adding, changing or deleting the HTTP password.
  */
-{template .HttpPasswordUpdate kind="text"}
+{template HttpPasswordUpdate kind="text"}
   {@param email: ?}
   The HTTP password was {$email.operation} on Gerrit Code Review at
   {sp}{$email.gerritHost}.
diff --git a/resources/com/google/gerrit/server/mail/HttpPasswordUpdateHtml.soy b/resources/com/google/gerrit/server/mail/HttpPasswordUpdateHtml.soy
index 3c4594c..e28aaaa 100644
--- a/resources/com/google/gerrit/server/mail/HttpPasswordUpdateHtml.soy
+++ b/resources/com/google/gerrit/server/mail/HttpPasswordUpdateHtml.soy
@@ -16,7 +16,7 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-{template .HttpPasswordUpdateHtml}
+{template HttpPasswordUpdateHtml}
   {@param email: ?}
   <p>
     The HTTP password was {$email.operation} on Gerrit Code Review
diff --git a/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy b/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy
index 16c5c8d..378785a 100644
--- a/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy
+++ b/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy
@@ -16,7 +16,9 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-{template .InboundEmailRejectionFooter kind="text"}
+import * as noReplyFooter from 'com/google/gerrit/server/mail/NoReplyFooter.soy';
+
+{template InboundEmailRejectionFooter kind="text"}
   {\n}
   {\n}
   Thus, no actions were taken by Gerrit in response to this email,
@@ -24,7 +26,7 @@
   {\n}
   This email was sent in response to an email coming from this address.
   In case you did not send Gerrit an email, feel free to ignore this.
-  {call .NoReplyFooter /}
+  {call noReplyFooter.NoReplyFooter /}
 {/template}
 
 /**
@@ -32,39 +34,39 @@
  * to warning users of error in inbound emails
  */
 
-{template .InboundEmailRejection_PARSING_ERROR kind="text"}
+{template InboundEmailRejection_PARSING_ERROR kind="text"}
   Gerrit Code Review was unable to parse your email.{\n}
   This might be because your email did not quote Gerrit's email,
   because you are using an unsupported email client,
   or because of a bug.
-  {call .InboundEmailRejectionFooter /}
+  {call InboundEmailRejectionFooter /}
 {/template}
 
-{template .InboundEmailRejection_UNKNOWN_ACCOUNT kind="text"}
+{template InboundEmailRejection_UNKNOWN_ACCOUNT kind="text"}
   Gerrit Code Review was unable to match your email to an account.{\n}
   This may happen if several accounts are linked to this email address.
-  {call .InboundEmailRejectionFooter /}
+  {call InboundEmailRejectionFooter /}
 {/template}
 
-{template .InboundEmailRejection_INACTIVE_ACCOUNT kind="text"}
+{template InboundEmailRejection_INACTIVE_ACCOUNT kind="text"}
   Your account on this Gerrit Code Review instance is marked as inactive,
   so your email has been ignored. {\n}
   If you think this is an error, please contact your Gerrit instance administrator.
   {\n}{\n}
   This email was sent in response to an email coming from this address.
   In case you did not send Gerrit an email, feel free to ignore this.
-  {call .NoReplyFooter /}
+  {call noReplyFooter.NoReplyFooter /}
 {/template}
 
-{template .InboundEmailRejection_INTERNAL_EXCEPTION kind="text"}
+{template InboundEmailRejection_INTERNAL_EXCEPTION kind="text"}
   Gerrit Code Review encountered an internal exception and was unable to fulfil your request.
   {\n}
   This might be caused by an ongoing maintenance or a data corruption.
-  {call .InboundEmailRejectionFooter /}
+  {call InboundEmailRejectionFooter /}
 {/template}
 
-{template .InboundEmailRejection_COMMENT_REJECTED kind="text"}
+{template InboundEmailRejection_COMMENT_REJECTED kind="text"}
   Gerrit Code Review rejected one or more comments because they did not pass validation, or
   because the maximum number of comments per change would be exceeded.
-  {call .InboundEmailRejectionFooter /}
+  {call InboundEmailRejectionFooter /}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy b/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy
index 8762e10..f0b18d2 100644
--- a/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy
+++ b/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy
@@ -16,8 +16,11 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
+import * as noReplyFooter from 'com/google/gerrit/server/mail/NoReplyFooter.soy';
+import * as noReplyFooterHtml from 'com/google/gerrit/server/mail/NoReplyFooterHtml.soy';
 
-{template .InboundEmailRejectionFooterHtml}
+
+{template InboundEmailRejectionFooterHtml}
   <p>
     Thus, no actions were taken by Gerrit in response to this email,
     and you should use the Gerrit website to continue.
@@ -25,7 +28,7 @@
   <p>
     In case you did not send Gerrit an email, feel free to ignore this.
   </p>
-  {call .NoReplyFooterHtml /}
+  {call noReplyFooterHtml.NoReplyFooterHtml /}
 {/template}
 
 /**
@@ -33,7 +36,7 @@
  * to warning users of error in inbound emails
  */
 
-{template .InboundEmailRejectionHtml_PARSING_ERROR}
+{template InboundEmailRejectionHtml_PARSING_ERROR}
   <p>
     Gerrit Code Review was unable to parse your email.
   </p>
@@ -42,20 +45,20 @@
     because you are using an unsupported email client,
     or because of a bug.
   </p>
-  {call .InboundEmailRejectionFooterHtml /}
+  {call InboundEmailRejectionFooterHtml /}
 {/template}
 
-{template .InboundEmailRejectionHtml_UNKNOWN_ACCOUNT}
+{template InboundEmailRejectionHtml_UNKNOWN_ACCOUNT}
   <p>
     Gerrit Code Review was unable to match your email to an account.
   </p>
   <p>
     This may happen if several accounts are linked to this email address.
   </p>
-  {call .InboundEmailRejectionFooterHtml /}
+  {call InboundEmailRejectionFooterHtml /}
 {/template}
 
-{template .InboundEmailRejectionHtml_INACTIVE_ACCOUNT}
+{template InboundEmailRejectionHtml_INACTIVE_ACCOUNT}
   <p>
     Your account on this Gerrit Code Review instance is marked as inactive,
     so your email has been ignored.
@@ -66,23 +69,23 @@
   <p>
     In case you did not send Gerrit an email, feel free to ignore this.
   </p>
-  {call .NoReplyFooter /}
+  {call noReplyFooter.NoReplyFooter /}
 {/template}
 
-{template .InboundEmailRejectionHtml_INTERNAL_EXCEPTION}
+{template InboundEmailRejectionHtml_INTERNAL_EXCEPTION}
   <p>
     Gerrit Code Review encountered an internal exception and was unable to fulfil your request.
   </p>
   <p>
     This might be caused by an ongoing maintenance or a data corruption.
   <p>
-  {call .InboundEmailRejectionFooterHtml /}
+  {call InboundEmailRejectionFooterHtml /}
 {/template}
 
-{template .InboundEmailRejectionHtml_COMMENT_REJECTED}
+{template InboundEmailRejectionHtml_COMMENT_REJECTED}
   <p>
     Gerrit Code Review rejected one or more comments because they did not pass validation, or
     because the maximum number of comments per change would be exceeded.
   </p>
-  {call .InboundEmailRejectionFooterHtml /}
+  {call InboundEmailRejectionFooterHtml /}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/Merged.soy b/resources/com/google/gerrit/server/mail/Merged.soy
index 899d1c0..b586851 100644
--- a/resources/com/google/gerrit/server/mail/Merged.soy
+++ b/resources/com/google/gerrit/server/mail/Merged.soy
@@ -21,7 +21,7 @@
  * The .Merged template will determine the contents of the email related to
  * a change successfully merged to the head.
  */
-{template .Merged kind="text"}
+{template Merged kind="text"}
   {@param change: ?}
   {@param email: ?}
   {@param fromName: ?}
@@ -39,4 +39,5 @@
     {$email.unifiedDiff}
     {\n}
   {/if}
+  {$email.stickyApprovalDiff}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/MergedHtml.soy b/resources/com/google/gerrit/server/mail/MergedHtml.soy
index f0a47c7..53c1645 100644
--- a/resources/com/google/gerrit/server/mail/MergedHtml.soy
+++ b/resources/com/google/gerrit/server/mail/MergedHtml.soy
@@ -16,7 +16,9 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-{template .MergedHtml}
+import * as mailTemplate from 'com/google/gerrit/server/mail/Private.soy';
+
+{template MergedHtml}
   {@param diffLines: ?}
   {@param email: ?}
   {@param fromName: ?}
@@ -26,15 +28,20 @@
 
   {if $email.changeUrl}
     <p>
-      {call .ViewChangeButton data="all" /}
+      {call mailTemplate.ViewChangeButton data="all" /}
     </p>
   {/if}
 
   <div style="white-space:pre-wrap">{$email.approvals}</div>
 
-  {call .Pre}{param content: $email.changeDetail /}{/call}
+  {call mailTemplate.Pre}
+    {param content: $email.changeDetail /}
+  {/call}
 
   {if $email.includeDiff}
-    {call .UnifiedDiff}{param diffLines: $diffLines /}{/call}
+    {call mailTemplate.UnifiedDiff}
+      {param diffLines: $diffLines /}
+    {/call}
   {/if}
+  <div style="white-space:pre-wrap">{$email.stickyApprovalDiff}</div>
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/NewChange.soy b/resources/com/google/gerrit/server/mail/NewChange.soy
index fa447e9..fe99ba4 100644
--- a/resources/com/google/gerrit/server/mail/NewChange.soy
+++ b/resources/com/google/gerrit/server/mail/NewChange.soy
@@ -20,7 +20,7 @@
  * The .NewChange template will determine the contents of the email related to a
  * user submitting a new change for review.
  */
-{template .NewChange kind="text"}
+{template NewChange kind="text"}
   {@param change: ?}
   {@param email: ?}
   {@param ownerName: ?}
diff --git a/resources/com/google/gerrit/server/mail/NewChangeHtml.soy b/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
index e16b213..756d6ce 100644
--- a/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
+++ b/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
@@ -16,7 +16,9 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-{template .NewChangeHtml}
+import * as mailTemplate from 'com/google/gerrit/server/mail/Private.soy';
+
+{template NewChangeHtml}
   {@param diffLines: ?}
   {@param email: ?}
   {@param fromName: ?}
@@ -41,20 +43,26 @@
 
   {if $email.changeUrl}
     <p>
-      {call .ViewChangeButton data="all" /}
+      {call mailTemplate.ViewChangeButton data="all" /}
     </p>
   {/if}
 
-  {call .Pre}{param content: $email.changeDetail /}{/call}
+  {call mailTemplate.Pre}
+    {param content: $email.changeDetail /}
+  {/call}
 
   {if $email.sshHost}
-    {call .Pre}{param content kind="html"}
-      git pull ssh:{print '//'}{$email.sshHost}/{$projectName}
-          {sp}{$patchSet.refName}
-    {/param}{/call}
+    {call mailTemplate.Pre}
+      {param content kind="html"}
+        git pull ssh:{print '//'}{$email.sshHost}/{$projectName}
+        {sp}{$patchSet.refName}
+      {/param}
+    {/call}
   {/if}
 
   {if $email.includeDiff}
-    {call .UnifiedDiff}{param diffLines: $diffLines /}{/call}
+    {call mailTemplate.UnifiedDiff}
+      {param diffLines: $diffLines /}
+    {/call}
   {/if}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/NoReplyFooter.soy b/resources/com/google/gerrit/server/mail/NoReplyFooter.soy
index 1443100..d309e90 100644
--- a/resources/com/google/gerrit/server/mail/NoReplyFooter.soy
+++ b/resources/com/google/gerrit/server/mail/NoReplyFooter.soy
@@ -16,7 +16,7 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-{template .NoReplyFooter kind="text"}
+{template NoReplyFooter kind="text"}
   {\n}
   This is a send-only email address.  Replies to this message will not be read
   or answered.
diff --git a/resources/com/google/gerrit/server/mail/NoReplyFooterHtml.soy b/resources/com/google/gerrit/server/mail/NoReplyFooterHtml.soy
index 93df527..1baf5ab 100644
--- a/resources/com/google/gerrit/server/mail/NoReplyFooterHtml.soy
+++ b/resources/com/google/gerrit/server/mail/NoReplyFooterHtml.soy
@@ -16,7 +16,7 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-{template .NoReplyFooterHtml}
+{template NoReplyFooterHtml}
   <p>
     This is a send-only email address.  Replies to this message will not be read
     or answered.
diff --git a/resources/com/google/gerrit/server/mail/Private.soy b/resources/com/google/gerrit/server/mail/Private.soy
index 510f15e..7920c21 100644
--- a/resources/com/google/gerrit/server/mail/Private.soy
+++ b/resources/com/google/gerrit/server/mail/Private.soy
@@ -23,7 +23,7 @@
 /**
  * Private template to generate "View Change" buttons.
  */
-{template .ViewChangeButton}
+{template ViewChangeButton}
   {@param email: ?}
   <a href="{$email.changeUrl}">View Change</a>
 {/template}
@@ -31,7 +31,7 @@
 /**
  * Private template to render PRE block with consistent font-sizing.
  */
-{template .Pre}
+{template Pre}
   {@param content: ?}
   {let $preStyle kind="css"}
     font-family: monospace,monospace; // Use this to avoid browsers scaling down
@@ -54,7 +54,7 @@
  * This mechanism encodes as little structure as possible in order to depend on
  * the Soy autoescape mechanism for all of the content.
  */
-{template .WikiFormat}
+{template WikiFormat}
   {@param content: ?}
   {let $blockquoteStyle kind="css"}
     border-left: 1px solid #aaa;
@@ -72,10 +72,10 @@
       <p style="{$pStyle}">{$block.text|changeNewlineToBr}</p>
     {elseif $block.type == 'quote'}
       <blockquote style="{$blockquoteStyle}">
-        {call .WikiFormat}{param content: $block.quotedBlocks /}{/call}
+        {call WikiFormat}{param content: $block.quotedBlocks /}{/call}
       </blockquote>
     {elseif $block.type == 'pre'}
-      {call .Pre}{param content: $block.text /}{/call}
+      {call Pre}{param content: $block.text /}{/call}
     {elseif $block.type == 'list'}
       <ul>
         {for $item in $block.items}
@@ -86,7 +86,7 @@
   {/for}
 {/template}
 
-{template .UnifiedDiff}
+{template UnifiedDiff}
   {@param diffLines: ?}
   {let $addStyle kind="css"}
     color: hsl(120, 100%, 40%);
diff --git a/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy b/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
index ee03de0..bde8152 100644
--- a/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
+++ b/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
@@ -20,7 +20,7 @@
  * The .RegisterNewEmail template will determine the contents of the email
  * related to registering new email accounts.
  */
-{template .RegisterNewEmail kind="text"}
+{template RegisterNewEmail kind="text"}
   {@param email: ?}
   Welcome to Gerrit Code Review at {$email.gerritHost}.{\n}
 
diff --git a/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy b/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy
index 033d145..e3ec3a5 100644
--- a/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy
+++ b/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy
@@ -16,7 +16,7 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-{template .RegisterNewEmailHtml}
+{template RegisterNewEmailHtml}
   {@param email: ?}
   <p>
     Welcome to Gerrit Code Review at {$email.gerritHost}.
diff --git a/resources/com/google/gerrit/server/mail/RemoveFromAttentionSet.soy b/resources/com/google/gerrit/server/mail/RemoveFromAttentionSet.soy
index f116adb..a329f7b0 100644
--- a/resources/com/google/gerrit/server/mail/RemoveFromAttentionSet.soy
+++ b/resources/com/google/gerrit/server/mail/RemoveFromAttentionSet.soy
@@ -20,7 +20,7 @@
  * The .RemoveFromAttentionSet template will determine the contents of the email related to a
  * user being added to the attention set.
  */
-{template .RemoveFromAttentionSet kind="text"}
+{template RemoveFromAttentionSet kind="text"}
   {@param change: ?}
   {@param coverLetter: ?}
   {@param email: ?}
diff --git a/resources/com/google/gerrit/server/mail/RemoveFromAttentionSetHtml.soy b/resources/com/google/gerrit/server/mail/RemoveFromAttentionSetHtml.soy
index 55eef13..c1c6185 100644
--- a/resources/com/google/gerrit/server/mail/RemoveFromAttentionSetHtml.soy
+++ b/resources/com/google/gerrit/server/mail/RemoveFromAttentionSetHtml.soy
@@ -16,7 +16,9 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-{template .RemoveFromAttentionSetHtml}
+import * as mailTemplate from 'com/google/gerrit/server/mail/Private.soy';
+
+{template RemoveFromAttentionSetHtml}
   {@param coverLetter: ?}
   {@param email: ?}
   {@param fromName: ?}
@@ -33,11 +35,11 @@
 
   {if $email.changeUrl}
     <p>
-      {call .ViewChangeButton data="all" /}
+      {call mailTemplate.ViewChangeButton data="all" /}
     </p>
   {/if}
 
   {if $coverLetter}
     <div style="white-space:pre-wrap">{$coverLetter}</div>
   {/if}
-{/template}
\ No newline at end of file
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy b/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
index bb84cf1..6dffa6b 100644
--- a/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
+++ b/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
@@ -20,7 +20,7 @@
  * The .ReplacePatchSet template will determine the contents of the email
  * related to a user submitting a new patchset for a change.
  */
-{template .ReplacePatchSet kind="text"}
+{template ReplacePatchSet kind="text"}
   {@param change: ?}
   {@param email: ?}
   {@param fromEmail: ?}
diff --git a/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy b/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
index 96cba5f..57c6db6 100644
--- a/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
+++ b/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
@@ -16,7 +16,9 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-{template .ReplacePatchSetHtml}
+import * as mailTemplate from 'com/google/gerrit/server/mail/Private.soy';
+
+{template ReplacePatchSetHtml}
   {@param change: ?}
   {@param email: ?}
   {@param fromName: ?}
@@ -35,16 +37,20 @@
 
   {if $email.changeUrl}
     <p>
-      {call .ViewChangeButton data="all" /}
+      {call mailTemplate.ViewChangeButton data="all" /}
     </p>
   {/if}
 
-  {call .Pre}{param content: $email.changeDetail /}{/call}
+  {call mailTemplate.Pre}
+    {param content: $email.changeDetail /}
+  {/call}
 
   {if $email.sshHost}
-    {call .Pre}{param content kind="html"}
-      git pull ssh:{print '//'}{$email.sshHost}/{$projectName}{sp}
-          {$patchSet.refName}
-    {/param}{/call}
+    {call mailTemplate.Pre}
+      {param content kind="html"}
+        git pull ssh:{print '//'}{$email.sshHost}/{$projectName}{sp}
+        {$patchSet.refName}
+      {/param}
+    {/call}
   {/if}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/Restored.soy b/resources/com/google/gerrit/server/mail/Restored.soy
index 0ec65b30..e09f95f 100644
--- a/resources/com/google/gerrit/server/mail/Restored.soy
+++ b/resources/com/google/gerrit/server/mail/Restored.soy
@@ -20,7 +20,7 @@
  * The .Restored template will determine the contents of the email related to a
  * change being restored.
  */
-{template .Restored kind="text"}
+{template Restored kind="text"}
   {@param change: ?}
   {@param coverLetter: ?}
   {@param email: ?}
diff --git a/resources/com/google/gerrit/server/mail/RestoredHtml.soy b/resources/com/google/gerrit/server/mail/RestoredHtml.soy
index bcd358f..19c4b99 100644
--- a/resources/com/google/gerrit/server/mail/RestoredHtml.soy
+++ b/resources/com/google/gerrit/server/mail/RestoredHtml.soy
@@ -16,7 +16,9 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-{template .RestoredHtml}
+import * as mailTemplate from 'com/google/gerrit/server/mail/Private.soy';
+
+{template RestoredHtml}
   {@param email: ?}
   {@param fromName: ?}
   <p>
@@ -25,7 +27,7 @@
 
   {if $email.changeUrl}
     <p>
-      {call .ViewChangeButton data="all" /}
+      {call mailTemplate.ViewChangeButton data="all" /}
     </p>
   {/if}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/Reverted.soy b/resources/com/google/gerrit/server/mail/Reverted.soy
index 32a65c6..bdfd0ad 100644
--- a/resources/com/google/gerrit/server/mail/Reverted.soy
+++ b/resources/com/google/gerrit/server/mail/Reverted.soy
@@ -20,7 +20,7 @@
  * The .Reverted template will determine the contents of the email related
  * to a change being reverted.
  */
-{template .Reverted kind="text"}
+{template Reverted kind="text"}
   {@param change: ?}
   {@param coverLetter: ?}
   {@param email: ?}
diff --git a/resources/com/google/gerrit/server/mail/RevertedHtml.soy b/resources/com/google/gerrit/server/mail/RevertedHtml.soy
index 69260ad..d7b60df 100644
--- a/resources/com/google/gerrit/server/mail/RevertedHtml.soy
+++ b/resources/com/google/gerrit/server/mail/RevertedHtml.soy
@@ -16,7 +16,9 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-{template .RevertedHtml}
+import * as mailTemplate from 'com/google/gerrit/server/mail/Private.soy';
+
+{template RevertedHtml}
   {@param email: ?}
   {@param fromName: ?}
   <p>
@@ -25,7 +27,7 @@
 
   {if $email.changeUrl}
     <p>
-      {call .ViewChangeButton data="all" /}
+      {call mailTemplate.ViewChangeButton data="all" /}
     </p>
   {/if}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/SetAssignee.soy b/resources/com/google/gerrit/server/mail/SetAssignee.soy
index 1fdf690..5e83cfb 100644
--- a/resources/com/google/gerrit/server/mail/SetAssignee.soy
+++ b/resources/com/google/gerrit/server/mail/SetAssignee.soy
@@ -20,7 +20,7 @@
  * The .SetAssignee template will determine the contents of the email related
  * to a user being assigned to a change.
  */
-{template .SetAssignee kind="text"}
+{template SetAssignee kind="text"}
   {@param change: ?}
   {@param email: ?}
   {@param fromName: ?}
diff --git a/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy b/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
index 1826314..4ff6cc1 100644
--- a/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
+++ b/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
@@ -16,7 +16,9 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-{template .SetAssigneeHtml}
+import * as mailTemplate from 'com/google/gerrit/server/mail/Private.soy';
+
+{template SetAssigneeHtml}
   {@param diffLines: ?}
   {@param email: ?}
   {@param fromName: ?}
@@ -29,20 +31,26 @@
 
   {if $email.changeUrl}
     <p>
-      {call .ViewChangeButton data="all" /}
+      {call mailTemplate.ViewChangeButton data="all" /}
     </p>
   {/if}
 
-  {call .Pre}{param content: $email.changeDetail /}{/call}
+  {call mailTemplate.Pre}
+    {param content: $email.changeDetail /}
+  {/call}
 
   {if $email.sshHost}
-    {call .Pre}{param content kind="html"}
-      git pull ssh:{print '//'}{$email.sshHost}/{$projectName}
-          {sp}{$patchSet.refName}
-    {/param}{/call}
+    {call mailTemplate.Pre}
+      {param content kind="html"}
+        git pull ssh:{print '//'}{$email.sshHost}/{$projectName}
+        {sp}{$patchSet.refName}
+      {/param}
+    {/call}
   {/if}
 
   {if $email.includeDiff}
-    {call .UnifiedDiff}{param diffLines: $diffLines /}{/call}
+    {call mailTemplate.UnifiedDiff}
+      {param diffLines: $diffLines /}
+    {/call}
   {/if}
 {/template}
diff --git a/resources/com/google/gerrit/server/mime/mime-types.properties b/resources/com/google/gerrit/server/mime/mime-types.properties
index 41566c8..6ab682c 100644
--- a/resources/com/google/gerrit/server/mime/mime-types.properties
+++ b/resources/com/google/gerrit/server/mime/mime-types.properties
@@ -232,6 +232,7 @@
 toml = text/x-toml
 tpl = text/x-smarty
 ts = application/typescript
+tsx = text/tsx
 ttcn = text/x-ttcn
 ttcnpp = text/x-ttcn
 ttcn3 = text/x-ttcn
diff --git a/tools/bzl/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/coverage.sh b/tools/coverage.sh
index c92d5cf..b20de31 100755
--- a/tools/coverage.sh
+++ b/tools/coverage.sh
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/usr/bin/env bash
 #
 # Usage
 #
@@ -7,6 +7,12 @@
 # COVERAGE_CPUS defaults to 2, and the default destination is a temp
 # dir.
 
+bazel_bin=$(which bazelisk 2>/dev/null)
+if [[ -z "$bazel_bin" ]]; then
+    echo "Warning: bazelisk is not installed; falling back to bazel."
+    bazel_bin=bazel
+fi
+
 genhtml=$(which genhtml)
 if [[ -z "${genhtml}" ]]; then
     echo "Install 'genhtml' (contained in the 'lcov' package)"
@@ -22,7 +28,7 @@
 
 # coverage is expensive to run; use --jobs=2 to avoid overloading the
 # machine.
-bazel coverage -k --jobs=${COVERAGE_CPUS:-2} -- ...
+${bazel_bin} coverage -k --jobs=${COVERAGE_CPUS:-2} -- ...
 
 # The coverage data contains filenames relative to the Java root, and
 # genhtml has no logic to search these elsewhere. Workaround this
@@ -33,18 +39,27 @@
 cp -r {java,javatests}/* ${destdir}/java
 
 mkdir -p ${destdir}/plugins
-for plugin in `find plugins/ -maxdepth 1 -type d`
+for plugin in `find plugins/ -mindepth 1 -maxdepth 1 -type d`
 do
   mkdir -p ${destdir}/${plugin}/java
-  cp -r plugins/*/{java,javatests}/* ${destdir}/${plugin}/java
+  if [ -e ${plugin}/java/ ]
+    then cp -r ${plugin}/java/* ${destdir}/${plugin}/java
+  fi
+  if [ -e ${plugin}/javatests/ ]
+    then cp -r ${plugin}/javatests/* ${destdir}/${plugin}/java
+  fi
 
   # for backwards compatibility support plugins with old file structure
   mkdir -p ${destdir}/${plugin}/src/{main,test}/java
-  cp -r plugins/*/src/main/java/* ${destdir}/${plugin}/src/main/java
-  cp -r plugins/*/src/test/java/* ${destdir}/${plugin}/src/test/java
+  if [ -e ${plugin}/src/main/java/ ]
+    then cp -r ${plugin}/src/main/java/* ${destdir}/${plugin}/src/main/java/
+  fi
+  if [ -e ${plugin}/src/test/java/ ]
+    then cp -r ${plugin}/src/test/java/* ${destdir}/${plugin}/src/test/java/
+  fi
 done
 
-base=$(bazel info bazel-testlogs)
+base=$(${bazel_bin} info bazel-testlogs)
 for f in $(find ${base}  -name 'coverage.dat') ; do
   cp $f ${destdir}/$(echo $f| sed "s|${base}/||" | sed "s|/|_|g")
 done
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/BUILD b/tools/js/BUILD
index fedaf7f..1a272e2 100644
--- a/tools/js/BUILD
+++ b/tools/js/BUILD
@@ -1 +1 @@
-exports_files(["run_npm_binary.py"])
+exports_files(["run_npm_binary.py", "eslint-chdir.js"])
diff --git a/tools/js/eslint-chdir.js b/tools/js/eslint-chdir.js
new file mode 100644
index 0000000..5aea704
--- /dev/null
+++ b/tools/js/eslint-chdir.js
@@ -0,0 +1,30 @@
+/**
+ * @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.
+ */
+
+// Eslint 7 introduced a breaking change - it uses the current workdir instead
+// of the configuration file directory for resolving relative paths:
+// https://eslint.org/docs/user-guide/migrating-to-7.0.0#base-path-change
+// This file is loaded before the eslint and sets the current directory
+// back to the location of configuration file.
+
+const path = require('path');
+const configParamIndex =
+    process.argv.findIndex(arg => arg === '-c' || arg === '---config');
+if (configParamIndex >= 0 && configParamIndex + 1 < process.argv.length) {
+  const dirName = path.dirname(process.argv[configParamIndex + 1]);
+  process.chdir(dirName);
+}
diff --git a/tools/js/eslint.bzl b/tools/js/eslint.bzl
index 586b1c5..b32e2bc 100644
--- a/tools/js/eslint.bzl
+++ b/tools/js/eslint.bzl
@@ -57,9 +57,11 @@
         config,
         ignore,
         "//tools/js/eslint-rules:eslint-rules-srcs",
+        "//tools/js:eslint-chdir.js",
         eslint_rules_toplevel_file,
     ] + plugins + data
     common_templated_args = [
+        "--node_options=--require=$$(rlocation $(rootpath //tools/js:eslint-chdir.js))",
         "--ext",
         ",".join(extensions),
         "-c",
@@ -85,7 +87,7 @@
             "*_test_require_patch.js",
             "--ignore-pattern",
             "*_test_loader.js",
-            native.package_name(),
+            "./", # Relative to the config file location
         ],
         # Should not run sandboxed.
         tags = [
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index 5074a74..718f8d0 100644
--- a/tools/maven/gerrit-acceptance-framework_pom.xml
+++ b/tools/maven/gerrit-acceptance-framework_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-acceptance-framework</artifactId>
-  <version>3.3.3-SNAPSHOT</version>
+  <version>3.4.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
   <description>Framework for Gerrit's acceptance tests</description>
@@ -47,6 +47,9 @@
       <name>Han-Wen Nienhuys</name>
     </developer>
     <developer>
+      <name>Joerg Zieren</name>
+    </developer>
+    <developer>
       <name>Jacek Centkowski</name>
     </developer>
     <developer>
@@ -79,6 +82,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 d9a0580..a415f24 100644
--- a/tools/maven/gerrit-extension-api_pom.xml
+++ b/tools/maven/gerrit-extension-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-extension-api</artifactId>
-  <version>3.3.3-SNAPSHOT</version>
+  <version>3.4.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
@@ -47,6 +47,9 @@
       <name>Han-Wen Nienhuys</name>
     </developer>
     <developer>
+      <name>Joerg Zieren</name>
+    </developer>
+    <developer>
       <name>Jacek Centkowski</name>
     </developer>
     <developer>
@@ -79,6 +82,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 f909139..5e58fdd 100644
--- a/tools/maven/gerrit-plugin-api_pom.xml
+++ b/tools/maven/gerrit-plugin-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-api</artifactId>
-  <version>3.3.3-SNAPSHOT</version>
+  <version>3.4.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
@@ -47,6 +47,9 @@
       <name>Han-Wen Nienhuys</name>
     </developer>
     <developer>
+      <name>Joerg Zieren</name>
+    </developer>
+    <developer>
       <name>Jacek Centkowski</name>
     </developer>
     <developer>
@@ -79,6 +82,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 179ca40..3b3a055 100644
--- a/tools/maven/gerrit-war_pom.xml
+++ b/tools/maven/gerrit-war_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-war</artifactId>
-  <version>3.3.3-SNAPSHOT</version>
+  <version>3.4.0-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
@@ -47,6 +47,9 @@
       <name>Han-Wen Nienhuys</name>
     </developer>
     <developer>
+      <name>Joerg Zieren</name>
+    </developer>
+    <developer>
       <name>Jacek Centkowski</name>
     </developer>
     <developer>
@@ -79,6 +82,9 @@
     <developer>
       <name>Sven Selberg</name>
     </developer>
+    <developer>
+      <name>Tao Zhou</name>
+    </developer>
   </developers>
 
   <mailingLists>
diff --git a/tools/node_tools/node_modules_licenses/BUILD b/tools/node_tools/node_modules_licenses/BUILD
index 581b3a9..b88ec24 100644
--- a/tools/node_tools/node_modules_licenses/BUILD
+++ b/tools/node_tools/node_modules_licenses/BUILD
@@ -8,9 +8,9 @@
     name = "licenses-map",
     srcs = glob(["*.ts"]),
     compiler = "//tools/node_tools:tsc_wrapped-bin",
-    node_modules = "@tools_npm//:node_modules",
     tsconfig = "tsconfig.json",
     deps = [
+        "@tools_npm//@bazel/typescript",
         "@tools_npm//@types/node",
     ],
 )
diff --git a/tools/node_tools/node_modules_licenses/installed-node-modules-map.ts b/tools/node_tools/node_modules_licenses/installed-node-modules-map.ts
index 3f4955e..49beda3 100644
--- a/tools/node_tools/node_modules_licenses/installed-node-modules-map.ts
+++ b/tools/node_tools/node_modules_licenses/installed-node-modules-map.ts
@@ -45,8 +45,12 @@
 export class InsalledPackagesBuilder {
   private readonly rootPathToPackageMap: Map<DirPath, InstalledPackage> = new Map();
 
+  public constructor(private readonly nonPackages: Set<string>) {
+  }
+
   public addPackageJson(packageJsonPath: string) {
     const pack = this.createInstalledPackage(packageJsonPath);
+    if (!pack) return;
     this.rootPathToPackageMap.set(pack.rootPath, pack)
   }
   public addFile(file: string) {
@@ -60,19 +64,23 @@
    * For example for the packageJsonFile='/a/node_modules/b/node_modules/d/e/package.json'
    * the package name is 'd/e'
    */
-  private createInstalledPackage(packageJsonFile: string): InstalledPackage {
+  private createInstalledPackage(packageJsonFile: string): InstalledPackage | undefined {
     const nameParts: Array<string> = [];
     const rootPath = path.dirname(packageJsonFile);
     let currentDir = rootPath;
     while(currentDir != "") {
       const partName = path.basename(currentDir);
       if(partName === "node_modules") {
+        const packageName = nameParts.reverse().join("/");
         const version = JSON.parse(fs.readFileSync(packageJsonFile, {encoding: 'utf-8'}))["version"];
         if(!version) {
+          if (this.nonPackages.has(packageName)) {
+            return undefined;
+          }
           fail(`Can't get version for ${packageJsonFile}`)
         }
         return {
-          name: nameParts.reverse().join("/"),
+          name: packageName,
           rootPath: rootPath,
           version: version,
           files: []
diff --git a/tools/node_tools/node_modules_licenses/licenses-map.ts b/tools/node_tools/node_modules_licenses/licenses-map.ts
index 9f277e5..7dfb23e 100644
--- a/tools/node_tools/node_modules_licenses/licenses-map.ts
+++ b/tools/node_tools/node_modules_licenses/licenses-map.ts
@@ -216,7 +216,13 @@
 
   /** getInstalledPackages Collects information about all installed packages */
   private getInstalledPackages(nodeModulesFiles: ReadonlyArray<string>): InstalledPackage[] {
-    const builder = new InsalledPackagesBuilder();
+    const fullNonPackageNames: string[] = [];
+    for (const p of this.packages) {
+      if (p.nonPackages) {
+        fullNonPackageNames.push(...p.nonPackages.map(name => `${p.name}/${name}`));
+      }
+    }
+    const builder = new InsalledPackagesBuilder(new Set(fullNonPackageNames));
     // Register all package.json files - such files exists in the root folder of each module
     nodeModulesFiles.filter(f => path.basename(f) === "package.json")
       .forEach(packageJsonFile => builder.addPackageJson(packageJsonFile));
diff --git a/tools/node_tools/node_modules_licenses/package-license-info.ts b/tools/node_tools/node_modules_licenses/package-license-info.ts
index c5cdb0f..79dea09 100644
--- a/tools/node_tools/node_modules_licenses/package-license-info.ts
+++ b/tools/node_tools/node_modules_licenses/package-license-info.ts
@@ -67,4 +67,6 @@
   versions?: string[];
   /** Predicate to select files to apply license. */
   filesFilter?: FilesFilter;
+  /** List of nested directories with package.json files, that are not real packages*/
+  nonPackages?: string[];
 }
diff --git a/tools/node_tools/node_modules_licenses/tsconfig.json b/tools/node_tools/node_modules_licenses/tsconfig.json
index 2046c394..cb7bb60 100644
--- a/tools/node_tools/node_modules_licenses/tsconfig.json
+++ b/tools/node_tools/node_modules_licenses/tsconfig.json
@@ -1,5 +1,13 @@
 {
   "compilerOptions": {
+    "plugins": [
+      {
+        "name": "@bazel/tsetse",
+        "disabledRules": [
+          "must-type-assert-json-parse"
+        ]
+      }
+    ],
     "target": "es6",
     "module": "commonjs",
     "allowSyntheticDefaultImports": true,
diff --git a/tools/node_tools/package.json b/tools/node_tools/package.json
index 1030877..9acbd07 100644
--- a/tools/node_tools/package.json
+++ b/tools/node_tools/package.json
@@ -3,8 +3,8 @@
   "description": "Gerrit Build Tools",
   "browser": false,
   "dependencies": {
-    "@bazel/rollup": "^2.0.0",
-    "@bazel/typescript": "^2.0.0",
+    "@bazel/rollup": "^3.2.0",
+    "@bazel/typescript": "^3.2.0",
     "@types/node": "^10.17.12",
     "@types/parse5": "^4.0.0",
     "@types/parse5-html-rewriting-stream": "^5.1.2",
@@ -13,10 +13,10 @@
     "parse5-html-rewriting-stream": "^5.1.1",
     "polymer-bundler": "^4.0.10",
     "polymer-cli": "^1.9.11",
-    "rollup": "^1.27.5",
+    "rollup": "^2.3.4",
     "rollup-plugin-node-resolve": "^5.2.0",
     "rollup-plugin-terser": "^5.1.3",
-    "typescript": "3.9.5"
+    "typescript": "4.0.5"
   },
   "devDependencies": {},
   "license": "Apache-2.0",
diff --git a/tools/node_tools/polygerrit_app_preprocessor/BUILD b/tools/node_tools/polygerrit_app_preprocessor/BUILD
index b5ee34f..fa3ce56 100644
--- a/tools/node_tools/polygerrit_app_preprocessor/BUILD
+++ b/tools/node_tools/polygerrit_app_preprocessor/BUILD
@@ -7,7 +7,6 @@
 ts_library(
     name = "preprocessor",
     srcs = glob(["*.ts"]),
-    node_modules = "@tools_npm//:node_modules",
     tsconfig = "tsconfig.json",
     deps = [
         "//tools/node_tools/utils",
diff --git a/tools/node_tools/utils/BUILD b/tools/node_tools/utils/BUILD
index 5c407ca..2196012 100644
--- a/tools/node_tools/utils/BUILD
+++ b/tools/node_tools/utils/BUILD
@@ -5,7 +5,6 @@
 ts_library(
     name = "utils",
     srcs = glob(["*.ts"]),
-    node_modules = "@tools_npm//:node_modules",
     tsconfig = "tsconfig.json",
     deps = [
         "@tools_npm//:node_modules",
diff --git a/tools/node_tools/yarn.lock b/tools/node_tools/yarn.lock
index 993bfe9..45a0c89 100644
--- a/tools/node_tools/yarn.lock
+++ b/tools/node_tools/yarn.lock
@@ -492,15 +492,15 @@
     lodash "^4.17.13"
     to-fast-properties "^2.0.0"
 
-"@bazel/rollup@^2.0.0":
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-2.0.0.tgz#1980cb3f6922227659260bfdca99c457889a5bc1"
-  integrity sha512-mifUfCZbD1RIhfowh4N8E4881ag3FChz7F4z35wxMOP52g1q3+6Bvh5wv9iysFQopxGmS5jNEj3Dq/CWtSoOnw==
+"@bazel/rollup@^3.2.0":
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-3.2.0.tgz#4241a5767e12e57b01279a539af2537c2d01924a"
+  integrity sha512-Wkw6L+hor/+FzpDswri7IlWAbKyShnUZRx59fG06+qqVhpNaS3V3lnZqVytMlLLT4oSP8YSIzoXC5GkXgLI2/Q==
 
-"@bazel/typescript@^2.0.0":
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-2.0.0.tgz#2ff5615f09c733cc681ba2ada92b11c356b694cd"
-  integrity sha512-5FPkxULWIjAKLG5J1XvpXpY1/4IK39dAoWA/Hhg+16gXTES32fT8w42k96pb6BTaNnyBuYgIHBpELEAJ40OOAQ==
+"@bazel/typescript@^3.2.0":
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-3.2.0.tgz#299bd173fe04f98407ab9be4f654662c1c28470e"
+  integrity sha512-RKdy9ThbcUAqZR3AJK7AR/nxbJqdHi7pPayIGUSMIpxVkeTxVRQpf1aGe2H02HdZ9fR/uk1xXhO/Ff9TLvTgHQ==
   dependencies:
     protobufjs "6.8.8"
     semver "5.6.0"
@@ -950,9 +950,9 @@
   integrity sha512-N33cKXGSqhOYaPiT4xUGsYlPPDwFtQM/6QxJxuMXA/7BcySW+lkn2yigWP7vfs4daiL/7NJNU6DMCqg5N4B+xQ==
 
 "@types/node@^10.1.0":
-  version "10.17.27"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.27.tgz#391cb391c75646c8ad2a7b6ed3bbcee52d1bdf19"
-  integrity sha512-J0oqm9ZfAXaPdwNXMMgAhylw5fhmXkToJd06vuDUSAgEDZ/n/69/69UmyBZbc+zT34UnShuDSBqvim3SPnozJg==
+  version "10.17.42"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.42.tgz#90dd71b26fe4f4e2929df6b07e72ef2e9648a173"
+  integrity sha512-HElxYF7C/MSkuvlaHB2c+82zhXiuO49Cq056Dol8AQuTph7oJtduo2n6J8rFa+YhJyNgQ/Lm20ZaxqD0vxU0+Q==
 
 "@types/node@^10.17.12":
   version "10.17.24"
@@ -3732,6 +3732,11 @@
     bindings "^1.5.0"
     nan "^2.12.1"
 
+fsevents@~2.1.2:
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e"
+  integrity sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==
+
 function-bind@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
@@ -6827,7 +6832,7 @@
   dependencies:
     estree-walker "^0.6.1"
 
-rollup@^1.27.5, rollup@^1.3.0:
+rollup@^1.3.0:
   version "1.30.0"
   resolved "https://registry.yarnpkg.com/rollup/-/rollup-1.30.0.tgz#ae9c893804e8eaa8f8f74b0aaf7e7fb4374a9d01"
   integrity sha512-ANcmfaSQwpcJtZUTA0ZMNBtFcQ1B4A5FldlNqEK0WdWm9sHSKu93ffa2KV1ux8HA/yKIV/ZARV28m7rNdXJgEw==
@@ -6836,6 +6841,13 @@
     "@types/node" "*"
     acorn "^7.1.0"
 
+rollup@^2.3.4:
+  version "2.35.1"
+  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.35.1.tgz#e6bc8d10893556a638066f89e8c97f422d03968c"
+  integrity sha512-q5KxEyWpprAIcainhVy6HfRttD9kutQpHbeqDTWnqAFNJotiojetK6uqmcydNMymBEtC4I8bCYR+J3mTMqeaUA==
+  optionalDependencies:
+    fsevents "~2.1.2"
+
 run-async@^2.0.0, run-async@^2.2.0:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.3.0.tgz#0371ab4ae0bdd720d4166d7dfda64ff7a445a6c0"
@@ -7835,9 +7847,9 @@
   integrity sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==
 
 tslib@^1.8.1:
-  version "1.13.0"
-  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043"
-  integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==
+  version "1.14.1"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
+  integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
 
 tslib@^1.9.0:
   version "1.10.0"
@@ -7881,10 +7893,10 @@
   resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
   integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
 
-typescript@3.9.5:
-  version "3.9.5"
-  resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.5.tgz#586f0dba300cde8be52dd1ac4f7e1009c1b13f36"
-  integrity sha512-hSAifV3k+i6lEoCJ2k6R2Z/rp/H3+8sdmcn5NrS3/3kE7+RyZXm9aqvxWqjEXHAd8b0pShatpcdMTvEdvAJltQ==
+typescript@4.0.5:
+  version "4.0.5"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.0.5.tgz#ae9dddfd1069f1cb5beb3ef3b2170dd7c1332389"
+  integrity sha512-ywmr/VrTVCmNTJ6iV2LwIrfG1P+lv6luD8sUJs+2eI9NLGigaN+nUQc13iHqisq7bra9lnmUSYqbJvegraBOPQ==
 
 typical@^2.6.0, typical@^2.6.1:
   version "2.6.1"
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index 4b6c8ac..3f5ef20 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -1,5 +1,13 @@
 load("//tools/bzl:maven_jar.bzl", "maven_jar")
 
+GUAVA_VERSION = "30.1-jre"
+
+GUAVA_BIN_SHA1 = "00d0c3ce2311c9e36e73228da25a6e99b2ab826f"
+
+GUAVA_DOC_URL = "https://google.github.io/guava/releases/" + GUAVA_VERSION + "/api/docs/"
+
+TESTCONTAINERS_VERSION = "1.15.1"
+
 def declare_nongoogle_deps():
     """loads dependencies that are not used at Google.
 
@@ -27,12 +35,12 @@
         sha1 = "cb2f351bf4463751201f43bb99865235d5ba07ca",
     )
 
-    SSHD_VERS = "2.4.0"
+    SSHD_VERS = "2.6.0"
 
     maven_jar(
         name = "sshd-osgi",
         artifact = "org.apache.sshd:sshd-osgi:" + SSHD_VERS,
-        sha1 = "fc4551c1eeda35e4671b263297d37d2bca81c4d4",
+        sha1 = "40e365bb799e1bff3d31dc858b1e59a93c123f29",
     )
 
     maven_jar(
@@ -50,7 +58,7 @@
     maven_jar(
         name = "sshd-mina",
         artifact = "org.apache.sshd:sshd-mina:" + SSHD_VERS,
-        sha1 = "8aa8715d07bd61ad8315df66d43c0c04b1b755c8",
+        sha1 = "d22138ba75dee95e2123f0e53a9c514b2a766da9",
     )
 
     # elasticsearch-rest-client explicitly depends on this version
@@ -92,19 +100,10 @@
         sha1 = "76716d529710fc03d1d429b43e3cedd4419f78d4",
     )
 
-    # When upgrading elasticsearch-rest-client, also upgrade httpcore-nio
-    # and httpasyncclient as necessary. Consider also the other
-    # org.apache.httpcomponents dependencies in ../WORKSPACE.
-    maven_jar(
-        name = "elasticsearch-rest-client",
-        artifact = "org.elasticsearch.client:elasticsearch-rest-client:7.8.1",
-        sha1 = "59feefe006a96a39f83b0dfb6780847e06c1d0a8",
-    )
-
     maven_jar(
         name = "jackson-core",
-        artifact = "com.fasterxml.jackson.core:jackson-core:2.11.3",
-        sha1 = "c2351800432bdbdd8284c3f5a7f0782a352aa84a",
+        artifact = "com.fasterxml.jackson.core:jackson-core:2.12.0",
+        sha1 = "afe52c6947d9939170da7989612cef544115511a",
     )
 
     maven_jar(
@@ -136,8 +135,40 @@
         sha1 = "b66d3bedb14da604828a8693bb24fd78e36b0e9e",
     )
 
-    # Test-only dependencies below.
+    maven_jar(
+        name = "guava",
+        artifact = "com.google.guava:guava:" + GUAVA_VERSION,
+        sha1 = GUAVA_BIN_SHA1,
+    )
 
+    GUICE_VERS = "5.0.0-BETA-1"
+
+    maven_jar(
+        name = "guice-library",
+        artifact = "com.google.inject:guice:" + GUICE_VERS,
+        sha1 = "c5572be8a8b75ea50e0fdf54fa1f75a3141ab936",
+    )
+
+    maven_jar(
+        name = "guice-assistedinject",
+        artifact = "com.google.inject.extensions:guice-assistedinject:" + GUICE_VERS,
+        sha1 = "4d06eba0e08151b52d9e25a14e4f01eedf998bc3",
+    )
+
+    maven_jar(
+        name = "guice-servlet",
+        artifact = "com.google.inject.extensions:guice-servlet:" + GUICE_VERS,
+        sha1 = "373b9a4f1b6683d9a991410660d2c9adb9f06737",
+    )
+
+    # Keep this version of Soy synchronized with the version used in Gitiles.
+    maven_jar(
+        name = "soy",
+        artifact = "com.google.template:soy:2021-02-01",
+        sha1 = "8e833744832ba88059205a1e30e0898f925d8cb5",
+    )
+
+    # Test-only dependencies below.
     maven_jar(
         name = "cglib-3_2",
         artifact = "cglib:cglib-nodep:3.2.6",
@@ -172,8 +203,6 @@
         sha1 = "0f63b3b1da563767d04d2e4d3fc1ae0cdeffebe7",
     )
 
-    TESTCONTAINERS_VERSION = "1.15.1"
-
     maven_jar(
         name = "testcontainers",
         artifact = "org.testcontainers:testcontainers:" + TESTCONTAINERS_VERSION,
@@ -181,12 +210,6 @@
     )
 
     maven_jar(
-        name = "testcontainers-elasticsearch",
-        artifact = "org.testcontainers:elasticsearch:" + TESTCONTAINERS_VERSION,
-        sha1 = "6b778a270b7529fcb9b7a6f62f3ae9d38544ce2f",
-    )
-
-    maven_jar(
         name = "duct-tape",
         artifact = "org.rnorth.duct-tape:duct-tape:1.0.8",
         sha1 = "92edc22a9ab2f3e17c9bf700aaee377d50e8b530",
@@ -203,3 +226,35 @@
         artifact = "net.java.dev.jna:jna:5.5.0",
         sha1 = "0e0845217c4907822403912ad6828d8e0b256208",
     )
+
+    maven_jar(
+        name = "jimfs",
+        artifact = "com.google.jimfs:jimfs:1.2",
+        sha1 = "48462eb319817c90c27d377341684b6b81372e08",
+    )
+
+    TRUTH_VERS = "1.1"
+
+    maven_jar(
+        name = "truth",
+        artifact = "com.google.truth:truth:" + TRUTH_VERS,
+        sha1 = "6a096a16646559c24397b03f797d0c9d75ee8720",
+    )
+
+    maven_jar(
+        name = "truth-java8-extension",
+        artifact = "com.google.truth.extensions:truth-java8-extension:" + TRUTH_VERS,
+        sha1 = "258db6eb8df61832c5c059ed2bc2e1c88683e92f",
+    )
+
+    maven_jar(
+        name = "truth-liteproto-extension",
+        artifact = "com.google.truth.extensions:truth-liteproto-extension:" + TRUTH_VERS,
+        sha1 = "bf65afa13aa03330e739bcaa5d795fe0f10fbf20",
+    )
+
+    maven_jar(
+        name = "truth-proto-extension",
+        artifact = "com.google.truth.extensions:truth-proto-extension:" + TRUTH_VERS,
+        sha1 = "64cba89cf87c1d84cb8c81d06f0b9c482f10b4dc",
+    )
diff --git a/tools/remote-bazelrc b/tools/remote-bazelrc
index 0aeb8d5..22ee330 100644
--- a/tools/remote-bazelrc
+++ b/tools/remote-bazelrc
@@ -25,7 +25,7 @@
 # this higher can make builds faster by allowing more jobs to run in parallel.
 # Setting it too high can result in jobs that timeout, however, while waiting
 # for a remote machine to execute them.
-build:remote --jobs=50
+build:remote --jobs=100
 build:remote --disk_cache=
 
 # Set several flags related to specifying the platform, toolchain and java
@@ -42,9 +42,6 @@
 # Set various strategies so that all actions execute remotely. Mixing remote
 # and local execution will lead to errors unless the toolchain and remote
 # machine exactly match the host machine.
-build:remote --spawn_strategy=remote,sandboxed
-build:remote --strategy=Javac=remote
-build:remote --strategy=Genrule=remote
 build:remote --define=EXECUTOR=remote
 
 # Enable the remote cache so action results can be shared across machines,
@@ -68,6 +65,3 @@
 build:remote-cache --tls_enabled=true
 build:remote-cache --remote_timeout=3600
 build:remote-cache --auth_enabled=true
-build:remote-cache --spawn_strategy=standalone
-build:remote-cache --strategy=Javac=standalone
-build:remote-cache --strategy=Genrule=standalone
diff --git a/version.bzl b/version.bzl
index 8be1b3e..066d07e 100644
--- a/version.bzl
+++ b/version.bzl
@@ -2,4 +2,4 @@
 # Used by :api_install and :api_deploy targets
 # when talking to the destination repository.
 #
-GERRIT_VERSION = "3.3.3-SNAPSHOT"
+GERRIT_VERSION = "3.4.0-SNAPSHOT"
diff --git a/yarn.lock b/yarn.lock
index 438cafd..a424d79 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -485,26 +485,42 @@
     lodash "^4.17.11"
     to-fast-properties "^2.0.0"
 
-"@bazel/rollup@^2.0.0":
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-2.0.0.tgz#1980cb3f6922227659260bfdca99c457889a5bc1"
-  integrity sha512-mifUfCZbD1RIhfowh4N8E4881ag3FChz7F4z35wxMOP52g1q3+6Bvh5wv9iysFQopxGmS5jNEj3Dq/CWtSoOnw==
+"@bazel/rollup@^3.2.0":
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-3.2.0.tgz#4241a5767e12e57b01279a539af2537c2d01924a"
+  integrity sha512-Wkw6L+hor/+FzpDswri7IlWAbKyShnUZRx59fG06+qqVhpNaS3V3lnZqVytMlLLT4oSP8YSIzoXC5GkXgLI2/Q==
 
-"@bazel/terser@^2.0.0":
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/@bazel/terser/-/terser-2.0.0.tgz#a841db8aefd7c51c216b34a26bc02a6c93d5e56a"
-  integrity sha512-6mBYcfzP6pWxycYZ8r4Lz5kgiWZ7n08bVHZBIRExFeqs7Yy92dD92LPeA9FZIzFiX00IuR9Q1Lqy23xH5q7FeQ==
+"@bazel/terser@^3.2.0":
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/@bazel/terser/-/terser-3.2.0.tgz#e53ad32733a0b231323b9eb55ebc2a3c65b10223"
+  integrity sha512-/yq4gST3t1mETkP6NjC05yEyIIL//4mbfLI56hE3CC/mm/xJ6UeooFVpUdlJREQEDRAdNWoiMesQ1ZtgpNPzFg==
 
-"@bazel/typescript@^2.0.0":
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-2.0.0.tgz#2ff5615f09c733cc681ba2ada92b11c356b694cd"
-  integrity sha512-5FPkxULWIjAKLG5J1XvpXpY1/4IK39dAoWA/Hhg+16gXTES32fT8w42k96pb6BTaNnyBuYgIHBpELEAJ40OOAQ==
+"@bazel/typescript@^3.2.0":
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-3.2.0.tgz#299bd173fe04f98407ab9be4f654662c1c28470e"
+  integrity sha512-RKdy9ThbcUAqZR3AJK7AR/nxbJqdHi7pPayIGUSMIpxVkeTxVRQpf1aGe2H02HdZ9fR/uk1xXhO/Ff9TLvTgHQ==
   dependencies:
     protobufjs "6.8.8"
     semver "5.6.0"
     source-map-support "0.5.9"
     tsutils "2.27.2"
 
+"@eslint/eslintrc@^0.2.2":
+  version "0.2.2"
+  resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.2.2.tgz#d01fc791e2fc33e88a29d6f3dc7e93d0cd784b76"
+  integrity sha512-EfB5OHNYp1F4px/LI/FEnGylop7nOqkQ1LRzCM0KccA2U8tvV8w01KBv37LbO7nW4H+YhKyo2LcJhRwjjV17QQ==
+  dependencies:
+    ajv "^6.12.4"
+    debug "^4.1.1"
+    espree "^7.3.0"
+    globals "^12.1.0"
+    ignore "^4.0.6"
+    import-fresh "^3.2.1"
+    js-yaml "^3.13.1"
+    lodash "^4.17.19"
+    minimatch "^3.0.4"
+    strip-json-comments "^3.1.1"
+
 "@mrmlnc/readdir-enhanced@^2.2.1":
   version "2.2.1"
   resolved "https://registry.yarnpkg.com/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde"
@@ -513,11 +529,32 @@
     call-me-maybe "^1.0.1"
     glob-to-regexp "^0.3.0"
 
+"@nodelib/fs.scandir@2.1.3":
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.3.tgz#3a582bdb53804c6ba6d146579c46e52130cf4a3b"
+  integrity sha512-eGmwYQn3gxo4r7jdQnkrrN6bY478C3P+a/y72IJukF8LjB6ZHeB3c+Ehacj3sYeSmUXGlnA67/PmbM9CVwL7Dw==
+  dependencies:
+    "@nodelib/fs.stat" "2.0.3"
+    run-parallel "^1.1.9"
+
+"@nodelib/fs.stat@2.0.3", "@nodelib/fs.stat@^2.0.2":
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.3.tgz#34dc5f4cabbc720f4e60f75a747e7ecd6c175bd3"
+  integrity sha512-bQBFruR2TAwoevBEd/NWMoAAtNGzTRgdrqnYCc7dhzfoNvqPzLyqlEQnzZ3kVnNrSp25iyxE00/3h2fqGAGArA==
+
 "@nodelib/fs.stat@^1.1.2":
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz#2b5a3ab3f918cca48a8c754c08168e3f03eba61b"
   integrity sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==
 
+"@nodelib/fs.walk@^1.2.3":
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.4.tgz#011b9202a70a6366e436ca5c065844528ab04976"
+  integrity sha512-1V9XOY4rDW0rehzbrcqAmHnz8e7SKvX27gh8Gt2WgB0+pdzdiLV83p72kZPU+jvMbS1qU5mauP2iOvO8rhmurQ==
+  dependencies:
+    "@nodelib/fs.scandir" "2.1.3"
+    fastq "^1.6.0"
+
 "@octokit/endpoint@^5.1.0":
   version "5.2.1"
   resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-5.2.1.tgz#e5ef98bc4a41fad62b17e71af1a1710f6076b8df"
@@ -769,11 +806,6 @@
   resolved "https://registry.yarnpkg.com/@types/escape-html/-/escape-html-0.0.20.tgz#cae698714dd61ebee5ab3f2aeb9a34ba1011735a"
   integrity sha512-6dhZJLbA7aOwkYB2GDGdIqJ20wmHnkDzaxV9PJXe7O02I2dSFTERzRB6JrX6cWKaS+VqhhY7cQUMCbO5kloFUw==
 
-"@types/eslint-visitor-keys@^1.0.0":
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d"
-  integrity sha512-OCutwjDZ4aFS6PB1UZ988C4YgwlBHJd6wCeQqaLdmadZ/7e+w79+hbMUFC1QXDNCmdyoRfAFdm0RypzwR+Qpag==
-
 "@types/estree@0.0.39":
   version "0.0.39"
   resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f"
@@ -884,6 +916,11 @@
   resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.5.tgz#dcce4430e64b443ba8945f0290fb564ad5bac6dd"
   integrity sha512-7+2BITlgjgDhH0vvwZU/HZJVyk+2XUlvxXe8dFMedNX/aMkaOq++rMAFXc0tM7ij15QaWlbdQASBR9dihi+bDQ==
 
+"@types/json5@^0.0.29":
+  version "0.0.29"
+  resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
+  integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4=
+
 "@types/launchpad@^0.6.0":
   version "0.6.0"
   resolved "https://registry.yarnpkg.com/@types/launchpad/-/launchpad-0.6.0.tgz#37296109b7f277f6e6c5fd7e0c0706bc918fbb51"
@@ -912,9 +949,9 @@
   integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==
 
 "@types/minimist@^1.2.0":
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.0.tgz#69a23a3ad29caf0097f06eda59b361ee2f0639f6"
-  integrity sha1-aaI6OtKcrwCX8G7aWbNh7i8GOfY=
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.1.tgz#283f669ff76d7b8260df8ab7a4262cc83d988256"
+  integrity sha512-fZQQafSREFyuZcdWFAExYjBiCL7AUCdgsk80iO0q4yihYYdcIiH28CcuPTGFgLOCC8RlW49GSQxdHwZP+I7CNg==
 
 "@types/mz@0.0.29":
   version "0.0.29"
@@ -937,9 +974,9 @@
   integrity sha512-rp7La3m845mSESCgsJePNL/JQyhkOJA6G4vcwvVgkDAwHhGdq5GCumxmPjEk1MZf+8p5ZQAUE7tqgQRQTXN7uQ==
 
 "@types/node@^10.1.0":
-  version "10.17.24"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.24.tgz#c57511e3a19c4b5e9692bb2995c40a3a52167944"
-  integrity sha512-5SCfvCxV74kzR3uWgTYiGxrd69TbT1I6+cMx1A5kEly/IVveJBimtAMlXiEyVFn5DvUFewQWxOOiJhlxeQwxgA==
+  version "10.17.49"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.49.tgz#ecf0b67bab4b84d0ec9b0709db4aac3824a51c4a"
+  integrity sha512-PGaJNs5IZz5XgzwJvL/1zRfZB7iaJ5BydZ8/Picm+lUNYoNO9iVTQkVy5eUh0dZDrx3rBOIs3GCbCRmMuYyqwg==
 
 "@types/node@^4.0.30":
   version "4.9.3"
@@ -1225,49 +1262,76 @@
     "@types/events" "*"
     "@types/inquirer" "*"
 
-"@typescript-eslint/eslint-plugin@2.31.0":
-  version "2.31.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-2.31.0.tgz#942c921fec5e200b79593c71fafb1e3f57aa2e36"
-  integrity sha512-iIC0Pb8qDaoit+m80Ln/aaeu9zKQdOLF4SHcGLarSeY1gurW6aU4JsOPMjKQwXlw70MvWKZQc6S2NamA8SJ/gg==
+"@typescript-eslint/eslint-plugin@^4.11.0", "@typescript-eslint/eslint-plugin@^4.2.0":
+  version "4.11.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.11.0.tgz#bc6c1e4175c0cf42083da4314f7931ad12f731cc"
+  integrity sha512-x4arJMXBxyD6aBXLm3W7mSDZRiABzy+2PCLJbL7OPqlp53VXhaA1HKK7R2rTee5OlRhnUgnp8lZyVIqjnyPT6g==
   dependencies:
-    "@typescript-eslint/experimental-utils" "2.31.0"
+    "@typescript-eslint/experimental-utils" "4.11.0"
+    "@typescript-eslint/scope-manager" "4.11.0"
+    debug "^4.1.1"
     functional-red-black-tree "^1.0.1"
     regexpp "^3.0.0"
+    semver "^7.3.2"
     tsutils "^3.17.1"
 
-"@typescript-eslint/experimental-utils@2.31.0":
-  version "2.31.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-2.31.0.tgz#a9ec514bf7fd5e5e82bc10dcb6a86d58baae9508"
-  integrity sha512-MI6IWkutLYQYTQgZ48IVnRXmLR/0Q6oAyJgiOror74arUMh7EWjJkADfirZhRsUMHeLJ85U2iySDwHTSnNi9vA==
+"@typescript-eslint/experimental-utils@4.11.0":
+  version "4.11.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.11.0.tgz#d1a47cc6cfe1c080ce4ead79267574b9881a1565"
+  integrity sha512-1VC6mSbYwl1FguKt8OgPs8xxaJgtqFpjY/UzUYDBKq4pfQ5lBvN2WVeqYkzf7evW42axUHYl2jm9tNyFsb8oLg==
   dependencies:
     "@types/json-schema" "^7.0.3"
-    "@typescript-eslint/typescript-estree" "2.31.0"
+    "@typescript-eslint/scope-manager" "4.11.0"
+    "@typescript-eslint/types" "4.11.0"
+    "@typescript-eslint/typescript-estree" "4.11.0"
     eslint-scope "^5.0.0"
     eslint-utils "^2.0.0"
 
-"@typescript-eslint/parser@2.31.0":
-  version "2.31.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-2.31.0.tgz#beddd4e8efe64995108b229b2862cd5752d40d6f"
-  integrity sha512-uph+w6xUOlyV2DLSC6o+fBDzZ5i7+3/TxAsH4h3eC64tlga57oMb96vVlXoMwjR/nN+xyWlsnxtbDkB46M2EPQ==
+"@typescript-eslint/parser@^4.2.0":
+  version "4.11.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.11.0.tgz#1dd3d7e42708c10ce9f3aa64c63c0ab99868b4e2"
+  integrity sha512-NBTtKCC7ZtuxEV5CrHUO4Pg2s784pvavc3cnz6V+oJvVbK4tH9135f/RBP6eUA2KHiFKAollSrgSctQGmHbqJQ==
   dependencies:
-    "@types/eslint-visitor-keys" "^1.0.0"
-    "@typescript-eslint/experimental-utils" "2.31.0"
-    "@typescript-eslint/typescript-estree" "2.31.0"
-    eslint-visitor-keys "^1.1.0"
-
-"@typescript-eslint/typescript-estree@2.31.0":
-  version "2.31.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-2.31.0.tgz#ac536c2d46672aa1f27ba0ec2140d53670635cfd"
-  integrity sha512-vxW149bXFXXuBrAak0eKHOzbcu9cvi6iNcJDzEtOkRwGHxJG15chiAQAwhLOsk+86p9GTr/TziYvw+H9kMaIgA==
-  dependencies:
+    "@typescript-eslint/scope-manager" "4.11.0"
+    "@typescript-eslint/types" "4.11.0"
+    "@typescript-eslint/typescript-estree" "4.11.0"
     debug "^4.1.1"
-    eslint-visitor-keys "^1.1.0"
-    glob "^7.1.6"
+
+"@typescript-eslint/scope-manager@4.11.0":
+  version "4.11.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.11.0.tgz#2d906537db8a3a946721699e4fc0833810490254"
+  integrity sha512-6VSTm/4vC2dHM3ySDW9Kl48en+yLNfVV6LECU8jodBHQOhO8adAVizaZ1fV0QGZnLQjQ/y0aBj5/KXPp2hBTjA==
+  dependencies:
+    "@typescript-eslint/types" "4.11.0"
+    "@typescript-eslint/visitor-keys" "4.11.0"
+
+"@typescript-eslint/types@4.11.0":
+  version "4.11.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.11.0.tgz#86cf95e7eac4ccfd183f9fcf1480cece7caf4ca4"
+  integrity sha512-XXOdt/NPX++txOQHM1kUMgJUS43KSlXGdR/aDyEwuAEETwuPt02Nc7v+s57PzuSqMbNLclblQdv3YcWOdXhQ7g==
+
+"@typescript-eslint/typescript-estree@4.11.0":
+  version "4.11.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.11.0.tgz#1144d145841e5987d61c4c845442a24b24165a4b"
+  integrity sha512-eA6sT5dE5RHAFhtcC+b5WDlUIGwnO9b0yrfGa1mIOIAjqwSQCpXbLiFmKTdRbQN/xH2EZkGqqLDrKUuYOZ0+Hg==
+  dependencies:
+    "@typescript-eslint/types" "4.11.0"
+    "@typescript-eslint/visitor-keys" "4.11.0"
+    debug "^4.1.1"
+    globby "^11.0.1"
     is-glob "^4.0.1"
     lodash "^4.17.15"
-    semver "^6.3.0"
+    semver "^7.3.2"
     tsutils "^3.17.1"
 
+"@typescript-eslint/visitor-keys@4.11.0":
+  version "4.11.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.11.0.tgz#906669a50f06aa744378bb84c7d5c4fdbc5b7d51"
+  integrity sha512-tRYKyY0i7cMk6v4UIOCjl1LhuepC/pc6adQqJk4Is3YcC6k46HvsV9Wl7vQoLbm9qADgeujiT7KdLrylvFIQ+A==
+  dependencies:
+    "@typescript-eslint/types" "4.11.0"
+    eslint-visitor-keys "^2.0.0"
+
 "@webcomponents/webcomponentsjs@^1.0.7":
   version "1.3.3"
   resolved "https://registry.yarnpkg.com/@webcomponents/webcomponentsjs/-/webcomponentsjs-1.3.3.tgz#5bb82a0d3210c836bd4623e13a4a93145cb9dc27"
@@ -1298,10 +1362,10 @@
   dependencies:
     acorn "^3.0.4"
 
-acorn-jsx@^5.1.0:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.1.0.tgz#294adb71b57398b0680015f0a38c563ee1db5384"
-  integrity sha512-tMUqwBWfLFbJbizRmEcWSLw6HnFzfdJs2sOJEOwwtVPMoH/0Ay+E703oZz78VSXZiiDcZrQ5XKjPIUQixhmgVw==
+acorn-jsx@^5.3.1:
+  version "5.3.1"
+  resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.1.tgz#fc8661e11b7ac1539c47dbfea2e72b3af34d267b"
+  integrity sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng==
 
 acorn@^3.0.4:
   version "3.3.0"
@@ -1318,10 +1382,10 @@
   resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.2.0.tgz#67f0da2fc339d6cfb5d6fb244fd449f33cd8bbe3"
   integrity sha512-8oe72N3WPMjA+2zVG71Ia0nXZ8DpQH+QyyHO+p06jT8eg8FGG3FbcUIi8KziHlAfheJQZeoqbvq1mQSQHXKYLw==
 
-acorn@^7.1.0:
-  version "7.1.0"
-  resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.1.0.tgz#949d36f2c292535da602283586c2477c57eb2d6c"
-  integrity sha512-kL5CuoXA/dgxlBbVrflsflzQ3PAas7RYZB52NOm/6839iVYJgKMJ3cQJD+t2i5+qFa8h3MDpEOJiS64E8JLnSQ==
+acorn@^7.4.0:
+  version "7.4.1"
+  resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa"
+  integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==
 
 adm-zip@~0.4.3:
   version "0.4.13"
@@ -1340,7 +1404,7 @@
   dependencies:
     es6-promisify "^5.0.0"
 
-ajv@^6.10.0, ajv@^6.10.2:
+ajv@^6.10.0:
   version "6.10.2"
   resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.10.2.tgz#d3cea04d6b017b2894ad69040fec8b623eb4bd52"
   integrity sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw==
@@ -1350,6 +1414,16 @@
     json-schema-traverse "^0.4.1"
     uri-js "^4.2.2"
 
+ajv@^6.12.4:
+  version "6.12.6"
+  resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4"
+  integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==
+  dependencies:
+    fast-deep-equal "^3.1.1"
+    fast-json-stable-stringify "^2.0.0"
+    json-schema-traverse "^0.4.1"
+    uri-js "^4.2.2"
+
 ajv@^6.5.5:
   version "6.10.1"
   resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.10.1.tgz#ebf8d3af22552df9dd049bfbe50cc2390e823593"
@@ -1381,6 +1455,11 @@
   dependencies:
     string-width "^3.0.0"
 
+ansi-colors@^4.1.1:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348"
+  integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==
+
 ansi-escapes@^1.1.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e"
@@ -1392,11 +1471,11 @@
   integrity sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==
 
 ansi-escapes@^4.2.1:
-  version "4.3.0"
-  resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.0.tgz#a4ce2b33d6b214b7950d8595c212f12ac9cc569d"
-  integrity sha512-EiYhwo0v255HUL6eDyuLrXEkTi7WwVCLAw+SeOQ7M7qdun1z1pum4DEm/nuqIVbPvi9RPPc9k9LbyBv6H0DwVg==
+  version "4.3.1"
+  resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.1.tgz#a5c47cc43181f1f38ffd7076837700d395522a61"
+  integrity sha512-JWF7ocqNrp8u9oqpgV+wH5ftbt+cfvv+PTjOvKLT3AdYly/LmORARfEVT1iyjwN+4MqE5UmVKoAdIBqeoCHgLA==
   dependencies:
-    type-fest "^0.8.1"
+    type-fest "^0.11.0"
 
 ansi-regex@^2.0.0:
   version "2.1.1"
@@ -1423,13 +1502,20 @@
   resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe"
   integrity sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=
 
-ansi-styles@^3.2.0, ansi-styles@^3.2.1:
+ansi-styles@^3.2.1:
   version "3.2.1"
   resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
   integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==
   dependencies:
     color-convert "^1.9.0"
 
+ansi-styles@^4.0.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937"
+  integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
+  dependencies:
+    color-convert "^2.0.1"
+
 ansi-styles@^4.1.0:
   version "4.2.1"
   resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.2.1.tgz#90ae75c424d008d2624c5bf29ead3177ebfcf359"
@@ -1559,13 +1645,15 @@
   resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2"
   integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=
 
-array-includes@^3.0.3:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.1.tgz#cdd67e6852bdf9c1215460786732255ed2459348"
-  integrity sha512-c2VXaCHl7zPsvpkFsw4nxvFie4fh1ur9bpcgsVkIjqn0H/Xwdg+7fv3n2r/isyS8EBj5b06M9kHyZuIr4El6WQ==
+array-includes@^3.1.1:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.2.tgz#a8db03e0b88c8c6aeddc49cb132f9bcab4ebf9c8"
+  integrity sha512-w2GspexNQpx+PutG3QpT437/BenZBj0M/MZGn5mzv/MofYqo0xmRHzn4lFsoDlWJ+THYsGJmFlW68WlDFx7VRw==
   dependencies:
+    call-bind "^1.0.0"
     define-properties "^1.1.3"
-    es-abstract "^1.17.0"
+    es-abstract "^1.18.0-next.1"
+    get-intrinsic "^1.0.1"
     is-string "^1.0.5"
 
 array-union@^1.0.1:
@@ -1575,6 +1663,11 @@
   dependencies:
     array-uniq "^1.0.1"
 
+array-union@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d"
+  integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==
+
 array-uniq@^1.0.1:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6"
@@ -1590,13 +1683,14 @@
   resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428"
   integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=
 
-array.prototype.flat@^1.2.1:
-  version "1.2.3"
-  resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.2.3.tgz#0de82b426b0318dbfdb940089e38b043d37f6c7b"
-  integrity sha512-gBlRZV0VSmfPIeWfuuy56XZMvbVfbEUnOXUvt3F/eUUUSyzlgLxhEX4YAEpxNAogRGehPSnfXyPtYyKAhkzQhQ==
+array.prototype.flat@^1.2.3:
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.2.4.tgz#6ef638b43312bd401b4c6199fdec7e2dc9e9a123"
+  integrity sha512-4470Xi3GAPAjZqFcljX2xzckv1qeKPizoNkiS0+O4IoPR2ZNpcjE0pkhdihlDouK+x6QOast26B4Q/O9DJnwSg==
   dependencies:
+    call-bind "^1.0.0"
     define-properties "^1.1.3"
-    es-abstract "^1.17.0-next.1"
+    es-abstract "^1.18.0-next.1"
 
 arraybuffer.slice@~0.0.7:
   version "0.0.7"
@@ -1608,11 +1702,6 @@
   resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d"
   integrity sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=
 
-arrify@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa"
-  integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==
-
 asn1@~0.2.3:
   version "0.2.4"
   resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136"
@@ -1630,10 +1719,10 @@
   resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367"
   integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=
 
-astral-regex@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9"
-  integrity sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==
+astral-regex@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31"
+  integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==
 
 async-each@^1.0.0:
   version "1.0.3"
@@ -2183,6 +2272,13 @@
     split-string "^3.0.2"
     to-regex "^3.0.1"
 
+braces@^3.0.1:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
+  integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
+  dependencies:
+    fill-range "^7.0.1"
+
 browser-capabilities@^1.0.0:
   version "1.1.4"
   resolved "https://registry.yarnpkg.com/browser-capabilities/-/browser-capabilities-1.1.4.tgz#a6bd657a07a134532ad66c722b8949904478b973"
@@ -2292,6 +2388,14 @@
     normalize-url "^4.1.0"
     responselike "^1.0.2"
 
+call-bind@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.0.tgz#24127054bb3f9bdcb4b1fb82418186072f77b8ce"
+  integrity sha512-AEXsYIyyDY3MCzbwdhzG3Jx1R0J2wetQyUynn6dYHAO+bg8l1k7jwZtRv4ryryFs7EP+NDlikJlVe59jr0cM2w==
+  dependencies:
+    function-bind "^1.1.1"
+    get-intrinsic "^1.0.0"
+
 call-me-maybe@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.1.tgz#26d208ea89e37b5cbde60250a15f031c16a4d66b"
@@ -2342,16 +2446,11 @@
   resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd"
   integrity sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=
 
-camelcase@^5.0.0, camelcase@^5.3.1:
+camelcase@^5.3.1:
   version "5.3.1"
   resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
   integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
 
-camelcase@^6.0.0:
-  version "6.0.0"
-  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.0.0.tgz#5259f7c30e35e278f1bdc2a4d91230b37cad981e"
-  integrity sha512-8KMDF1Vz2gzOq54ONPJS65IvTUaB1cHJ2DMM7MbPmLZljDH1qpzzLsWdiN9pHh6qvkRVDTi/07+eNGch/oLU4w==
-
 cancel-token@^0.1.1:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/cancel-token/-/cancel-token-0.1.1.tgz#c18197674bb1c84c1d6933ebf15d8d5a5ce79b4f"
@@ -2369,7 +2468,7 @@
   resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
   integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=
 
-chalk@*, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.3.0, chalk@^2.4.1, chalk@^2.4.2:
+chalk@*, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.3.0, chalk@^2.4.1, chalk@^2.4.2:
   version "2.4.2"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
   integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==
@@ -2397,7 +2496,7 @@
     ansi-styles "^4.1.0"
     supports-color "^7.1.0"
 
-chalk@^4.0.0:
+chalk@^4.0.0, chalk@^4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a"
   integrity sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==
@@ -2488,9 +2587,9 @@
   integrity sha1-T6kXw+WclKAEzWH47lCdplFocUM=
 
 cli-boxes@^2.2.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.0.tgz#538ecae8f9c6ca508e3c3c95b453fe93cb4c168d"
-  integrity sha512-gpaBrMAizVEANOpfZp/EEUixTXDyGt7DFzdK5hU+UbWt/J0lB0w20ncZj59Z9a93xHb9u12zF5BS6i9RKbtg4w==
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.1.tgz#ddd5035d25094fce220e9cab40a45840a440318f"
+  integrity sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==
 
 cli-cursor@^1.0.1:
   version "1.0.2"
@@ -2525,6 +2624,11 @@
   resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.0.tgz#ff19ede8a9a5e579324147b0c11f0fbcbabed639"
   integrity sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk=
 
+cli-width@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-3.0.0.tgz#a2f48437a2caa9a22436e794bf071ec9e61cedf6"
+  integrity sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==
+
 clone-buffer@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/clone-buffer/-/clone-buffer-1.0.0.tgz#e3e25b207ac4e701af721e2cb5a16792cac3dc58"
@@ -2691,10 +2795,10 @@
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a"
   integrity sha512-6tvAOO+D6OENvRAh524Dh9jcfKTYDQAqvqezbCW82xj5X0pSrcpxtvRKHLG0yBY6SD7PSDrJaj+0AiOcKVd1Xg==
 
-comment-parser@^0.7.2:
-  version "0.7.2"
-  resolved "https://registry.yarnpkg.com/comment-parser/-/comment-parser-0.7.2.tgz#baf6d99b42038678b81096f15b630d18142f4b8a"
-  integrity sha512-4Rjb1FnxtOcv9qsfuaNuVsmmVn4ooVoBHzYfyKteiXwIU84PClyGA5jASoFMwPV93+FPh9spwueXauxFJZkGAg==
+comment-parser@^0.7.6:
+  version "0.7.6"
+  resolved "https://registry.yarnpkg.com/comment-parser/-/comment-parser-0.7.6.tgz#0e743a53c8e646c899a1323db31f6cd337b10f12"
+  integrity sha512-GKNxVA7/iuTnAqGADlTWX4tkhzxZKXp5fLJqKTlQLHkE65XDUKutZ3BHaJC5IGcper2tT3QRD1xr4o3jNpgXXg==
 
 commondir@^1.0.1:
   version "1.0.1"
@@ -2914,7 +3018,7 @@
     shebang-command "^1.2.0"
     which "^1.2.9"
 
-cross-spawn@^7.0.0:
+cross-spawn@^7.0.2, cross-spawn@^7.0.3:
   version "7.0.3"
   resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
   integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==
@@ -3004,6 +3108,13 @@
   dependencies:
     ms "^2.1.1"
 
+debug@^4.3.1:
+  version "4.3.1"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee"
+  integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==
+  dependencies:
+    ms "2.1.2"
+
 debug@~3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
@@ -3019,7 +3130,7 @@
     decamelize "^1.1.0"
     map-obj "^1.0.0"
 
-decamelize@^1.1.0, decamelize@^1.1.2, decamelize@^1.2.0:
+decamelize@^1.1.0, decamelize@^1.1.2:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
   integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=
@@ -3046,7 +3157,7 @@
   resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
   integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
 
-deep-is@~0.1.3:
+deep-is@^0.1.3:
   version "0.1.3"
   resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34"
   integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=
@@ -3196,6 +3307,13 @@
     arrify "^1.0.1"
     path-type "^3.0.0"
 
+dir-glob@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f"
+  integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==
+  dependencies:
+    path-type "^4.0.0"
+
 doctrine@1.5.0:
   version "1.5.0"
   resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-1.5.0.tgz#379dce730f6166f76cefa4e6707a159b02c5a6fa"
@@ -3218,12 +3336,13 @@
   dependencies:
     esutils "^2.0.2"
 
-dom-serializer@0:
-  version "0.2.2"
-  resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51"
-  integrity sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==
+dom-serializer@^1.0.1:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.2.0.tgz#3433d9136aeb3c627981daa385fc7f32d27c48f1"
+  integrity sha512-n6kZFH/KlCrqs/1GHMOd5i2fd/beQHuehKdWvNNffbGHTr/almdhuVvTVFb3V7fglz+nC50fFusu3lY33h12pA==
   dependencies:
     domelementtype "^2.0.1"
+    domhandler "^4.0.0"
     entities "^2.0.0"
 
 dom-urls@^1.1.0:
@@ -3242,30 +3361,33 @@
     clone "^2.1.0"
     parse5 "^4.0.0"
 
-domelementtype@1, domelementtype@^1.3.1:
-  version "1.3.1"
-  resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f"
-  integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==
+domelementtype@^2.0.1, domelementtype@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.1.0.tgz#a851c080a6d1c3d94344aed151d99f669edf585e"
+  integrity sha512-LsTgx/L5VpD+Q8lmsXSHW2WpA+eBlZ9HPf3erD1IoPF00/3JKHZ3BknUVA2QGDNu69ZNmyFmCWBSO45XjYKC5w==
 
-domelementtype@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.0.1.tgz#1f8bdfe91f5a78063274e803b4bdcedf6e94f94d"
-  integrity sha512-5HOHUDsYZWV8FGWN0Njbr/Rn7f/eWSQi1v7+HsUVwXgn8nWWlL64zKDkS0n8ZmQ3mlWOMuXOnR+7Nx/5tMO5AQ==
-
-domhandler@^2.3.0:
-  version "2.4.2"
-  resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.4.2.tgz#8805097e933d65e85546f726d60f5eb88b44f803"
-  integrity sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==
+domhandler@^3.3.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-3.3.0.tgz#6db7ea46e4617eb15cf875df68b2b8524ce0037a"
+  integrity sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA==
   dependencies:
-    domelementtype "1"
+    domelementtype "^2.0.1"
 
-domutils@^1.5.1:
-  version "1.7.0"
-  resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a"
-  integrity sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==
+domhandler@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.0.0.tgz#01ea7821de996d85f69029e81fa873c21833098e"
+  integrity sha512-KPTbnGQ1JeEMQyO1iYXoagsI6so/C96HZiFyByU3T6iAzpXn8EGEvct6unm1ZGoed8ByO2oirxgwxBmqKF9haA==
   dependencies:
-    dom-serializer "0"
-    domelementtype "1"
+    domelementtype "^2.1.0"
+
+domutils@^2.4.2:
+  version "2.4.4"
+  resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.4.4.tgz#282739c4b150d022d34699797369aad8d19bbbd3"
+  integrity sha512-jBC0vOsECI4OMdD0GC9mGn7NXPLb+Qt6KW1YDQzeQYRUFKmNG8lh7mO5HiELfr+lLQE7loDVI4QcAxV80HS+RA==
+  dependencies:
+    dom-serializer "^1.0.1"
+    domelementtype "^2.0.1"
+    domhandler "^4.0.0"
 
 dot-prop@^3.0.0:
   version "3.0.0"
@@ -3282,9 +3404,9 @@
     is-obj "^1.0.0"
 
 dot-prop@^5.2.0:
-  version "5.2.0"
-  resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.2.0.tgz#c34ecc29556dc45f1f4c22697b6f4904e0cc4fcb"
-  integrity sha512-uEUyaDKoSQ1M4Oq8l45hSE26SnTxL6snNnqvK/VWx5wJhmff5z0FUVJDKDanor/6w3kzE3i7XZOk+7wC0EXr1A==
+  version "5.3.0"
+  resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.3.0.tgz#90ccce708cd9cd82cc4dc8c3ddd9abdd55b20e88"
+  integrity sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==
   dependencies:
     is-obj "^2.0.0"
 
@@ -3422,15 +3544,17 @@
     engine.io-parser "~2.2.0"
     ws "^7.1.2"
 
-entities@^1.1.1:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56"
-  integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==
+enquirer@^2.3.5:
+  version "2.3.6"
+  resolved "https://registry.yarnpkg.com/enquirer/-/enquirer-2.3.6.tgz#2a7fe5dd634a1e4125a975ec994ff5456dc3734d"
+  integrity sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==
+  dependencies:
+    ansi-colors "^4.1.1"
 
 entities@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.0.tgz#68d6084cab1b079767540d80e56a39b423e4abf4"
-  integrity sha512-D9f7V0JSRwIxlRI2mjMqufDrRDnx8p+eEOz7aUM9SuvF8gsBzra0/6tbjl1m8eQHrZlYj6PxqE00hZ1SAIKPLw==
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/entities/-/entities-2.1.0.tgz#992d3129cf7df6870b96c57858c249a120f8b8b5"
+  integrity sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==
 
 env-variable@0.0.x:
   version "0.0.5"
@@ -3459,22 +3583,23 @@
     string-template "~0.2.1"
     xtend "~4.0.0"
 
-es-abstract@^1.17.0, es-abstract@^1.17.0-next.1:
-  version "1.17.5"
-  resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.5.tgz#d8c9d1d66c8981fb9200e2251d799eee92774ae9"
-  integrity sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg==
+es-abstract@^1.18.0-next.1:
+  version "1.18.0-next.1"
+  resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.18.0-next.1.tgz#6e3a0a4bda717e5023ab3b8e90bec36108d22c68"
+  integrity sha512-I4UGspA0wpZXWENrdA0uHbnhte683t3qT/1VFH9aX2dA5PPSf6QW5HHXf5HImaqPmjXaVeVk4RGWnaylmV7uAA==
   dependencies:
     es-to-primitive "^1.2.1"
     function-bind "^1.1.1"
     has "^1.0.3"
     has-symbols "^1.0.1"
-    is-callable "^1.1.5"
-    is-regex "^1.0.5"
-    object-inspect "^1.7.0"
+    is-callable "^1.2.2"
+    is-negative-zero "^2.0.0"
+    is-regex "^1.1.1"
+    object-inspect "^1.8.0"
     object-keys "^1.1.1"
-    object.assign "^4.1.0"
-    string.prototype.trimleft "^2.1.1"
-    string.prototype.trimright "^2.1.1"
+    object.assign "^4.1.1"
+    string.prototype.trimend "^1.0.1"
+    string.prototype.trimstart "^1.0.1"
 
 es-to-primitive@^1.2.1:
   version "1.2.1"
@@ -3517,30 +3642,30 @@
   resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
   integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=
 
-eslint-config-google@^0.13.0:
-  version "0.13.0"
-  resolved "https://registry.yarnpkg.com/eslint-config-google/-/eslint-config-google-0.13.0.tgz#e277d16d2cb25c1ffd3fd13fb0035ad7421382fe"
-  integrity sha512-ELgMdOIpn0CFdsQS+FuxO+Ttu4p+aLaXHv9wA9yVnzqlUGV7oN/eRRnJekk7TCur6Cu2FXX0fqfIXRBaM14lpQ==
+eslint-config-google@^0.14.0:
+  version "0.14.0"
+  resolved "https://registry.yarnpkg.com/eslint-config-google/-/eslint-config-google-0.14.0.tgz#4f5f8759ba6e11b424294a219dbfa18c508bcc1a"
+  integrity sha512-WsbX4WbjuMvTdeVL6+J3rK1RGhCTqjsFjX7UMSMgZiyxxaNLkoJENbrGExzERFeoTpGw3F3FypTiWAP9ZXzkEw==
 
-eslint-config-prettier@^6.10.1:
-  version "6.11.0"
-  resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-6.11.0.tgz#f6d2238c1290d01c859a8b5c1f7d352a0b0da8b1"
-  integrity sha512-oB8cpLWSAjOVFEJhhyMZh6NOEOtBVziaqdDQ86+qhDHFbZXoRTM7pNSvFRfW/W/L/LrQ38C99J5CGuRBBzBsdA==
+eslint-config-prettier@^6.12.0:
+  version "6.15.0"
+  resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-6.15.0.tgz#7f93f6cb7d45a92f1537a70ecc06366e1ac6fed9"
+  integrity sha512-a1+kOYLR8wMGustcgAjdydMsQ2A/2ipRPwRKUmfYaSxc9ZPcrku080Ctl6zrZzZNs/U82MjSv+qKREkoq3bJaw==
   dependencies:
     get-stdin "^6.0.0"
 
-eslint-import-resolver-node@^0.3.2:
-  version "0.3.3"
-  resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.3.tgz#dbaa52b6b2816b50bc6711af75422de808e98404"
-  integrity sha512-b8crLDo0M5RSe5YG8Pu2DYBj71tSB6OvXkfzwbJU2w7y8P4/yo0MyF8jU26IEuEuHF2K5/gcAJE3LhQGqBBbVg==
+eslint-import-resolver-node@^0.3.4:
+  version "0.3.4"
+  resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.4.tgz#85ffa81942c25012d8231096ddf679c03042c717"
+  integrity sha512-ogtf+5AB/O+nM6DIeBUNr2fuT7ot9Qg/1harBfBtaP13ekEWFQEEMP94BCB7zaNW3gyY+8SHYF00rnqYwXKWOA==
   dependencies:
     debug "^2.6.9"
     resolve "^1.13.1"
 
-eslint-module-utils@^2.4.1:
-  version "2.5.2"
-  resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.5.2.tgz#7878f7504824e1b857dd2505b59a8e5eda26a708"
-  integrity sha512-LGScZ/JSlqGKiT8OC+cYRxseMjyqt6QO54nl281CK93unD89ijSeRV6An8Ci/2nvWVKe8K/Tqdm75RQoIOCr+Q==
+eslint-module-utils@^2.6.0:
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.6.0.tgz#579ebd094f56af7797d19c9866c9c9486629bfa6"
+  integrity sha512-6j9xxegbqe8/kZY8cYpcp0xhbK0EgJlg3g9mib3/miLaExuuwc3n5UEfSnU6hWMbT0FAYVvDbL9RrRgpUeQIvA==
   dependencies:
     debug "^2.6.9"
     pkg-dir "^2.0.0"
@@ -3553,44 +3678,44 @@
     eslint-utils "^2.0.0"
     regexpp "^3.0.0"
 
-eslint-plugin-html@^6.0.0:
-  version "6.0.0"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-html/-/eslint-plugin-html-6.0.0.tgz#28e5c3e71e6f612e07e73d7c215e469766628c13"
-  integrity sha512-PQcGippOHS+HTbQCStmH5MY1BF2MaU8qW/+Mvo/8xTa/ioeMXdSP+IiaBw2+nh0KEMfYQKuTz1Zo+vHynjwhbg==
+eslint-plugin-html@^6.1.1:
+  version "6.1.1"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-html/-/eslint-plugin-html-6.1.1.tgz#95aee151900b9bb2da5fa017b45cc64456a0a74e"
+  integrity sha512-JSe3ZDb7feKMnQM27XWGeoIjvP4oWQMJD9GZ6wW67J7/plVL87NK72RBwlvfc3tTZiYUchHhxAwtgEd1GdofDA==
   dependencies:
-    htmlparser2 "^3.10.1"
+    htmlparser2 "^5.0.1"
 
-eslint-plugin-import@^2.20.1:
-  version "2.20.1"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.20.1.tgz#802423196dcb11d9ce8435a5fc02a6d3b46939b3"
-  integrity sha512-qQHgFOTjguR+LnYRoToeZWT62XM55MBVXObHM6SKFd1VzDcX/vqT1kAz8ssqigh5eMj8qXcRoXXGZpPP6RfdCw==
+eslint-plugin-import@^2.22.1:
+  version "2.22.1"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.22.1.tgz#0896c7e6a0cf44109a2d97b95903c2bb689d7702"
+  integrity sha512-8K7JjINHOpH64ozkAhpT3sd+FswIZTfMZTjdx052pnWrgRCVfp8op9tbjpAk3DdUeI/Ba4C8OjdC0r90erHEOw==
   dependencies:
-    array-includes "^3.0.3"
-    array.prototype.flat "^1.2.1"
+    array-includes "^3.1.1"
+    array.prototype.flat "^1.2.3"
     contains-path "^0.1.0"
     debug "^2.6.9"
     doctrine "1.5.0"
-    eslint-import-resolver-node "^0.3.2"
-    eslint-module-utils "^2.4.1"
+    eslint-import-resolver-node "^0.3.4"
+    eslint-module-utils "^2.6.0"
     has "^1.0.3"
     minimatch "^3.0.4"
-    object.values "^1.1.0"
+    object.values "^1.1.1"
     read-pkg-up "^2.0.0"
-    resolve "^1.12.0"
+    resolve "^1.17.0"
+    tsconfig-paths "^3.9.0"
 
-eslint-plugin-jsdoc@^19.2.0:
-  version "19.2.0"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-19.2.0.tgz#f522b970878ae402b28ce62187305b33dfe2c834"
-  integrity sha512-QdNifBFLXCDGdy+26RXxcrqzEZarFWNybCZQVqJQYEYPlxd6lm+LPkrs6mCOhaGc2wqC6zqpedBQFX8nQJuKSw==
+eslint-plugin-jsdoc@^30.7.9:
+  version "30.7.9"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-30.7.9.tgz#b0a9881990edc1bc8a635467bad9edfae9ecbaaf"
+  integrity sha512-qMM0fNx7/6OCnIh3jRpIrEBAhTG1THNXXbr3yfJ8yqLrDbzJR98xsstX25xt9GCPlrjNc/bBpTHfJQOvn7nVMA==
   dependencies:
-    comment-parser "^0.7.2"
-    debug "^4.1.1"
-    jsdoctypeparser "^6.1.0"
-    lodash "^4.17.15"
-    object.entries-ponyfill "^1.0.1"
-    regextras "^0.7.0"
-    semver "^6.3.0"
-    spdx-expression-parse "^3.0.0"
+    comment-parser "^0.7.6"
+    debug "^4.3.1"
+    jsdoctypeparser "^9.0.0"
+    lodash "^4.17.20"
+    regextras "^0.7.1"
+    semver "^7.3.4"
+    spdx-expression-parse "^3.0.1"
 
 eslint-plugin-node@^11.1.0:
   version "11.1.0"
@@ -3604,17 +3729,10 @@
     resolve "^1.10.1"
     semver "^6.1.0"
 
-eslint-plugin-prettier@^3.1.2:
-  version "3.1.4"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-3.1.4.tgz#168ab43154e2ea57db992a2cd097c828171f75c2"
-  integrity sha512-jZDa8z76klRqo+TdGDTFJSavwbnWK2ZpqGKNZ+VvweMW516pDUMmQ2koXvxEE4JhzNvTv+radye/bWGBmA6jmg==
-  dependencies:
-    prettier-linter-helpers "^1.0.0"
-
-eslint-plugin-prettier@^3.1.3:
-  version "3.1.3"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-3.1.3.tgz#ae116a0fc0e598fdae48743a4430903de5b4e6ca"
-  integrity sha512-+HG5jmu/dN3ZV3T6eCD7a4BlAySdN7mLIbJYo0z1cFQuI+r2DiTJEFeF68ots93PsnrMxbzIZ2S/ieX+mkrBeQ==
+eslint-plugin-prettier@^3.1.4, eslint-plugin-prettier@^3.3.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-3.3.0.tgz#61e295349a65688ffac0b7808ef0a8244bdd8d40"
+  integrity sha512-tMTwO8iUWlSRZIwS9k7/E4vrTsfvsrcM5p1eftyuqWH25nKsz/o6/54I7jwQ/3zobISyC7wMy9ZsFwgTxOcOpQ==
   dependencies:
     prettier-linter-helpers "^1.0.0"
 
@@ -3626,14 +3744,15 @@
     esrecurse "^4.1.0"
     estraverse "^4.1.1"
 
-eslint-utils@^1.4.3:
-  version "1.4.3"
-  resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.4.3.tgz#74fec7c54d0776b6f67e0251040b5806564e981f"
-  integrity sha512-fbBN5W2xdY45KulGXmLHZ3c3FHfVYmKg0IrAKGOkT/464PQsx2UeIzfz1RmEci+KLm1bBaAzZAh8+/E+XAeZ8Q==
+eslint-scope@^5.1.1:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c"
+  integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==
   dependencies:
-    eslint-visitor-keys "^1.1.0"
+    esrecurse "^4.3.0"
+    estraverse "^4.1.1"
 
-eslint-utils@^2.0.0:
+eslint-utils@^2.0.0, eslint-utils@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-2.1.0.tgz#d2de5e03424e707dc10c74068ddedae708741b27"
   integrity sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==
@@ -3645,46 +3764,56 @@
   resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz#e2a82cea84ff246ad6fb57f9bde5b46621459ec2"
   integrity sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A==
 
-eslint@^6.6.0, eslint@^6.8.0:
-  version "6.8.0"
-  resolved "https://registry.yarnpkg.com/eslint/-/eslint-6.8.0.tgz#62262d6729739f9275723824302fb227c8c93ffb"
-  integrity sha512-K+Iayyo2LtyYhDSYwz5D5QdWw0hCacNzyq1Y821Xna2xSJj7cijoLLYmLxTQgcgZ9mC61nryMy9S7GRbYpI5Ig==
+eslint-visitor-keys@^1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz#30ebd1ef7c2fdff01c3a4f151044af25fab0523e"
+  integrity sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==
+
+eslint-visitor-keys@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.0.0.tgz#21fdc8fbcd9c795cc0321f0563702095751511a8"
+  integrity sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ==
+
+eslint@^7.10.0, eslint@^7.16.0:
+  version "7.16.0"
+  resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.16.0.tgz#a761605bf9a7b32d24bb7cde59aeb0fd76f06092"
+  integrity sha512-iVWPS785RuDA4dWuhhgXTNrGxHHK3a8HLSMBgbbU59ruJDubUraXN8N5rn7kb8tG6sjg74eE0RA3YWT51eusEw==
   dependencies:
     "@babel/code-frame" "^7.0.0"
+    "@eslint/eslintrc" "^0.2.2"
     ajv "^6.10.0"
-    chalk "^2.1.0"
-    cross-spawn "^6.0.5"
+    chalk "^4.0.0"
+    cross-spawn "^7.0.2"
     debug "^4.0.1"
     doctrine "^3.0.0"
-    eslint-scope "^5.0.0"
-    eslint-utils "^1.4.3"
-    eslint-visitor-keys "^1.1.0"
-    espree "^6.1.2"
-    esquery "^1.0.1"
+    enquirer "^2.3.5"
+    eslint-scope "^5.1.1"
+    eslint-utils "^2.1.0"
+    eslint-visitor-keys "^2.0.0"
+    espree "^7.3.1"
+    esquery "^1.2.0"
     esutils "^2.0.2"
-    file-entry-cache "^5.0.1"
+    file-entry-cache "^6.0.0"
     functional-red-black-tree "^1.0.1"
     glob-parent "^5.0.0"
     globals "^12.1.0"
     ignore "^4.0.6"
     import-fresh "^3.0.0"
     imurmurhash "^0.1.4"
-    inquirer "^7.0.0"
     is-glob "^4.0.0"
     js-yaml "^3.13.1"
     json-stable-stringify-without-jsonify "^1.0.1"
-    levn "^0.3.0"
-    lodash "^4.17.14"
+    levn "^0.4.1"
+    lodash "^4.17.19"
     minimatch "^3.0.4"
-    mkdirp "^0.5.1"
     natural-compare "^1.4.0"
-    optionator "^0.8.3"
+    optionator "^0.9.1"
     progress "^2.0.0"
-    regexpp "^2.0.1"
-    semver "^6.1.2"
-    strip-ansi "^5.2.0"
-    strip-json-comments "^3.0.1"
-    table "^5.2.3"
+    regexpp "^3.1.0"
+    semver "^7.2.1"
+    strip-ansi "^6.0.0"
+    strip-json-comments "^3.1.0"
+    table "^6.0.4"
     text-table "^0.2.0"
     v8-compile-cache "^2.0.3"
 
@@ -3696,26 +3825,26 @@
     acorn "^5.5.0"
     acorn-jsx "^3.0.0"
 
-espree@^6.1.2:
-  version "6.1.2"
-  resolved "https://registry.yarnpkg.com/espree/-/espree-6.1.2.tgz#6c272650932b4f91c3714e5e7b5f5e2ecf47262d"
-  integrity sha512-2iUPuuPP+yW1PZaMSDM9eyVf8D5P0Hi8h83YtZ5bPc/zHYjII5khoixIUTMO794NOY8F/ThF1Bo8ncZILarUTA==
+espree@^7.3.0, espree@^7.3.1:
+  version "7.3.1"
+  resolved "https://registry.yarnpkg.com/espree/-/espree-7.3.1.tgz#f2df330b752c6f55019f8bd89b7660039c1bbbb6"
+  integrity sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==
   dependencies:
-    acorn "^7.1.0"
-    acorn-jsx "^5.1.0"
-    eslint-visitor-keys "^1.1.0"
+    acorn "^7.4.0"
+    acorn-jsx "^5.3.1"
+    eslint-visitor-keys "^1.3.0"
 
 esprima@^4.0.0:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
   integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
 
-esquery@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.0.1.tgz#406c51658b1f5991a5f9b62b1dc25b00e3e5c708"
-  integrity sha512-SmiyZ5zIWH9VM+SRUReLS5Q8a7GxtRdxEBVZpm98rJM7Sb+A9DVCndXfkeFUd3byderg+EbDkfnevfCwynWaNA==
+esquery@^1.2.0:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.3.1.tgz#b78b5828aa8e214e29fb74c4d5b752e1c033da57"
+  integrity sha512-olpvt9QG0vniUBZspVRN6lwB7hOZoTRtT+jzR+tS4ffYx2mzbw+z0XCOk44aaLYKApNX5nMm+E+P6o25ip/DHQ==
   dependencies:
-    estraverse "^4.0.0"
+    estraverse "^5.1.0"
 
 esrecurse@^4.1.0:
   version "4.2.1"
@@ -3724,11 +3853,23 @@
   dependencies:
     estraverse "^4.1.0"
 
-estraverse@^4.0.0, estraverse@^4.1.0, estraverse@^4.1.1:
+esrecurse@^4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921"
+  integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==
+  dependencies:
+    estraverse "^5.2.0"
+
+estraverse@^4.1.0, estraverse@^4.1.1:
   version "4.3.0"
   resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d"
   integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==
 
+estraverse@^5.1.0, estraverse@^5.2.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.2.0.tgz#307df42547e6cc7324d3cf03c155d5cdb8c53880"
+  integrity sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==
+
 esutils@^2.0.2:
   version "2.0.2"
   resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b"
@@ -3770,19 +3911,19 @@
     signal-exit "^3.0.0"
     strip-eof "^1.0.0"
 
-execa@^4.0.0:
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/execa/-/execa-4.0.2.tgz#ad87fb7b2d9d564f70d2b62d511bee41d5cbb240"
-  integrity sha512-QI2zLa6CjGWdiQsmSkZoGtDx2N+cQIGb3yNolGTdjSQzydzLgYYf8LRuagp7S7fPimjcrzUDSUFd/MgzELMi4Q==
+execa@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/execa/-/execa-5.0.0.tgz#4029b0007998a841fbd1032e5f4de86a3c1e3376"
+  integrity sha512-ov6w/2LCiuyO4RLYGdpFGjkcs0wMTgGE8PrkTHikeUy5iJekXyPIKUjifk5CsE0pt7sMCrMZ3YNqoCj6idQOnQ==
   dependencies:
-    cross-spawn "^7.0.0"
-    get-stream "^5.0.0"
-    human-signals "^1.1.1"
+    cross-spawn "^7.0.3"
+    get-stream "^6.0.0"
+    human-signals "^2.1.0"
     is-stream "^2.0.0"
     merge-stream "^2.0.0"
-    npm-run-path "^4.0.0"
-    onetime "^5.1.0"
-    signal-exit "^3.0.2"
+    npm-run-path "^4.0.1"
+    onetime "^5.1.2"
+    signal-exit "^3.0.3"
     strip-final-newline "^2.0.0"
 
 exit-hook@^1.0.0:
@@ -3953,6 +4094,11 @@
   resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49"
   integrity sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=
 
+fast-deep-equal@^3.1.1:
+  version "3.1.3"
+  resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
+  integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
+
 fast-diff@^1.1.2:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03"
@@ -3970,12 +4116,24 @@
     merge2 "^1.2.3"
     micromatch "^3.1.10"
 
+fast-glob@^3.1.1:
+  version "3.2.4"
+  resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.4.tgz#d20aefbf99579383e7f3cc66529158c9b98554d3"
+  integrity sha512-kr/Oo6PX51265qeuCYsyGypiO5uJFgBS0jksyG7FUeCyQzNwYnzrNIMR1NXfkZXsMYXYLRAHgISHBz8gQcxKHQ==
+  dependencies:
+    "@nodelib/fs.stat" "^2.0.2"
+    "@nodelib/fs.walk" "^1.2.3"
+    glob-parent "^5.1.0"
+    merge2 "^1.3.0"
+    micromatch "^4.0.2"
+    picomatch "^2.2.1"
+
 fast-json-stable-stringify@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2"
   integrity sha1-1RQsDK7msRifh9OnYREGT4bIu/I=
 
-fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6:
+fast-levenshtein@^2.0.6:
   version "2.0.6"
   resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
   integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=
@@ -3985,6 +4143,13 @@
   resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.6.tgz#04b26106cc56681f51a044cfc0d76cf0008ac2c2"
   integrity sha512-q8BZ89jjc+mz08rSxROs8VsrBBcn1SIw1kq9NjolL509tkABRk9io01RAjSaEv1Xb2uFLt8VtRiZbGp5H8iDtg==
 
+fastq@^1.6.0:
+  version "1.10.0"
+  resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.10.0.tgz#74dbefccade964932cdf500473ef302719c652bb"
+  integrity sha512-NL2Qc5L3iQEsyYzweq7qfgy5OtXCmGzGvhElGEd/SoFWEMOEczNh5s5ocaF01HDetxz+p8ecjNPA6cZxxIHmzA==
+  dependencies:
+    reusify "^1.0.4"
+
 fd-slicer@~1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e"
@@ -4013,18 +4178,18 @@
     escape-string-regexp "^1.0.5"
 
 figures@^3.0.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/figures/-/figures-3.1.0.tgz#4b198dd07d8d71530642864af2d45dd9e459c4ec"
-  integrity sha512-ravh8VRXqHuMvZt/d8GblBeqDMkdJMBdv/2KntFH+ra5MXkO7nxNKpzQ3n6QD/2da1kH0aWmNISdvhM7gl2gVg==
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af"
+  integrity sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==
   dependencies:
     escape-string-regexp "^1.0.5"
 
-file-entry-cache@^5.0.1:
-  version "5.0.1"
-  resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-5.0.1.tgz#ca0f6efa6dd3d561333fb14515065c2fafdf439c"
-  integrity sha512-bCg29ictuBaKUwwArK4ouCaqDgLZcysCFLmM/Yn/FDoqndh/9vNuQfXRDvTuXKLxfD/JtZQGKFT8MGcJBK644g==
+file-entry-cache@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.0.tgz#7921a89c391c6d93efec2169ac6bf300c527ea0a"
+  integrity sha512-fqoO76jZ3ZnYrXLDRxBR1YvOvc0k844kcOg40bgsPrE25LAb/PDqTY+ho64Xh2c8ZXgIKldchCFHczG2UVRcWA==
   dependencies:
-    flat-cache "^2.0.1"
+    flat-cache "^3.0.4"
 
 filename-regex@^2.0.0:
   version "2.0.1"
@@ -4052,6 +4217,13 @@
     repeat-string "^1.6.1"
     to-regex-range "^2.1.0"
 
+fill-range@^7.0.1:
+  version "7.0.1"
+  resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"
+  integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==
+  dependencies:
+    to-regex-range "^5.0.1"
+
 filled-array@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/filled-array/-/filled-array-1.1.0.tgz#c3c4f6c663b923459a9aa29912d2d031f1507f84"
@@ -4146,19 +4318,18 @@
   dependencies:
     readable-stream "^2.0.2"
 
-flat-cache@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-2.0.1.tgz#5d296d6f04bda44a4630a301413bdbc2ec085ec0"
-  integrity sha512-LoQe6yDuUMDzQAEH8sgmh4Md6oZnc/7PjtwjNFSzveXqSHt6ka9fPBuso7IGf9Rz4uqnSnWiFH2B/zj24a5ReA==
+flat-cache@^3.0.4:
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.0.4.tgz#61b0338302b2fe9f957dcc32fc2a87f1c3048b11"
+  integrity sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==
   dependencies:
-    flatted "^2.0.0"
-    rimraf "2.6.3"
-    write "1.0.3"
+    flatted "^3.1.0"
+    rimraf "^3.0.2"
 
-flatted@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.1.tgz#69e57caa8f0eacbc281d2e2cb458d46fdb449e08"
-  integrity sha512-a1hQMktqW9Nmqr5aktAux3JMNqaucxGcjtjWnZLHX7yyPCmlSV3M54nGYbqT8K+0GhF3NBgmJCc3ma+WOgX8Jg==
+flatted@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.1.0.tgz#a5d06b4a8b01e3a63771daa5cb7a1903e2e57067"
+  integrity sha512-tW+UkmtNg/jv9CSofAKvgVcO7c2URjhTdW1ZTkcAritblu8tajiYy7YisnIflEwtKssCtOxpnBRoCB7iap0/TA==
 
 follow-redirects@^1.0.0:
   version "1.9.0"
@@ -4257,6 +4428,11 @@
     nan "^2.12.1"
     node-pre-gyp "^0.12.0"
 
+fsevents@~2.1.2:
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e"
+  integrity sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==
+
 function-bind@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
@@ -4281,6 +4457,15 @@
     strip-ansi "^3.0.1"
     wide-align "^1.1.0"
 
+get-intrinsic@^1.0.0, get-intrinsic@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.0.2.tgz#6820da226e50b24894e08859469dc68361545d49"
+  integrity sha512-aeX0vrFm21ILl3+JpFFRNe9aUvp6VFZb2/CTbgLb8j75kOhvoNYjt9d8KA/tJG4gSo8nzEDedRl0h7vDmBYRVg==
+  dependencies:
+    function-bind "^1.1.1"
+    has "^1.0.3"
+    has-symbols "^1.0.1"
+
 get-stdin@^4.0.1:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe"
@@ -4303,13 +4488,18 @@
   dependencies:
     pump "^3.0.0"
 
-get-stream@^5.0.0, get-stream@^5.1.0:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.1.0.tgz#01203cdc92597f9b909067c3e656cc1f4d3c4dc9"
-  integrity sha512-EXr1FOzrzTfGeL0gQdeFEvOMm2mzMOglyiOXSTpPC+iAjAKftbr3jpCMWynogwYnM+eSj9sHGc6wjIcDvYiygw==
+get-stream@^5.1.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3"
+  integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==
   dependencies:
     pump "^3.0.0"
 
+get-stream@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.0.tgz#3e0012cb6827319da2706e601a1583e8629a6718"
+  integrity sha512-A1B3Bh1UmL0bidM/YX2NsCOTnGJePL9rO/M+Mw3m9f2gUpfokS0hi5Eah0WSUEWZdZhIZtMjkIYS7mDfOqNHbg==
+
 get-value@^2.0.3, get-value@^2.0.6:
   version "2.0.6"
   resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28"
@@ -4367,6 +4557,13 @@
   dependencies:
     is-glob "^4.0.1"
 
+glob-parent@^5.1.0:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.1.tgz#b6c1ef417c4e5663ea498f1c45afac6916bbc229"
+  integrity sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ==
+  dependencies:
+    is-glob "^4.0.1"
+
 glob-stream@^5.3.2:
   version "5.3.5"
   resolved "https://registry.yarnpkg.com/glob-stream/-/glob-stream-5.3.5.tgz#a55665a9a8ccdc41915a87c701e32d4e016fad22"
@@ -4420,7 +4617,7 @@
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
-glob@^7.1.3, glob@^7.1.4, glob@^7.1.6:
+glob@^7.1.3, glob@^7.1.4:
   version "7.1.6"
   resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6"
   integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==
@@ -4440,11 +4637,11 @@
     ini "^1.3.4"
 
 global-dirs@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-2.0.1.tgz#acdf3bb6685bcd55cb35e8a052266569e9469201"
-  integrity sha512-5HqUqdhkEovj2Of/ms3IeS/EekcO54ytHRLV4PEY2rhRwrHXLQjeVEES0Lhka0xwNDtGYn58wyC4s5+MHsOO6A==
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-2.1.0.tgz#e9046a49c806ff04d6c1825e196c8f0091e8df4d"
+  integrity sha512-MG6kdOUh/xBnyo9cJFeIKkLEc1AyFq42QTU4XiX51i2NEdxLxLWXIjEjmqKeSuKR7pAZjTqUVoT2b2huxVLgYQ==
   dependencies:
-    ini "^1.3.5"
+    ini "1.3.7"
 
 global-modules@^0.2.3:
   version "0.2.3"
@@ -4501,6 +4698,18 @@
   resolved "https://registry.yarnpkg.com/globals/-/globals-9.18.0.tgz#aa3896b3e69b487f17e31ed2143d69a8e30c2d8a"
   integrity sha512-S0nG3CLEQiY/ILxqtztTWH/3iRRdyBLw6KMDxnKMchrtbj2OFmehVh0WUCfW3DUrIgx/qFrJPICrq4Z4sTR9UQ==
 
+globby@^11.0.1:
+  version "11.0.1"
+  resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.1.tgz#9a2bf107a068f3ffeabc49ad702c79ede8cfd357"
+  integrity sha512-iH9RmgwCmUJHi2z5o2l3eTtGBtXek1OYlHrbcxOYugyHLmAsZrPj43OtHThd62Buh/Vv6VyCBD2bdyWcGNQqoQ==
+  dependencies:
+    array-union "^2.1.0"
+    dir-glob "^3.0.1"
+    fast-glob "^3.1.1"
+    ignore "^5.1.4"
+    merge2 "^1.3.0"
+    slash "^3.0.0"
+
 globby@^4.0.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/globby/-/globby-4.1.0.tgz#080f54549ec1b82a6c60e631fc82e1211dbe95f8"
@@ -4629,25 +4838,25 @@
   dependencies:
     lodash "^4.17.2"
 
-gts@^2.0.2:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/gts/-/gts-2.0.2.tgz#b8b28de99361b5c5c24db30a375a0f546bbc04a4"
-  integrity sha512-SLytzl2IqKXf6kGULwr07XQ9lVsvjrzFD3OAA7DEfIQYuD+lKBPt/cZ/RYGxaWerY4PTfmnXT7KdxEr9Ec8uHQ==
+gts@^3.0.3:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/gts/-/gts-3.0.3.tgz#36716d87680c2d1a0e02867c91bb7169cede9369"
+  integrity sha512-XHFGhDzoyaHDVHlhNfz369erxuSEIyVMtJoRAz8Dt2NpidG5pOJzI/44lWhsLVigph64TgAWPWBsBTQFCQ1mTw==
   dependencies:
-    "@typescript-eslint/eslint-plugin" "2.31.0"
-    "@typescript-eslint/parser" "2.31.0"
-    chalk "^4.0.0"
-    eslint "^6.8.0"
-    eslint-config-prettier "^6.10.1"
+    "@typescript-eslint/eslint-plugin" "^4.2.0"
+    "@typescript-eslint/parser" "^4.2.0"
+    chalk "^4.1.0"
+    eslint "^7.10.0"
+    eslint-config-prettier "^6.12.0"
     eslint-plugin-node "^11.1.0"
-    eslint-plugin-prettier "^3.1.2"
-    execa "^4.0.0"
-    inquirer "^7.1.0"
-    meow "^7.0.0"
+    eslint-plugin-prettier "^3.1.4"
+    execa "^5.0.0"
+    inquirer "^7.3.3"
+    meow "^8.0.0"
     ncp "^2.0.0"
-    prettier "^2.0.4"
+    prettier "^2.1.2"
     rimraf "^3.0.2"
-    update-notifier "^4.1.0"
+    update-notifier "^5.0.0"
     write-file-atomic "^3.0.3"
 
 gulp-if@^2.0.2:
@@ -4833,6 +5042,13 @@
   resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.7.1.tgz#97f236977bd6e125408930ff6de3eec6281ec047"
   integrity sha512-7T/BxH19zbcCTa8XkMlbK5lTo1WtgkFi3GvdWEyNuc4Vex7/9Dqbnpsf4JMydcfj9HCg4zUWFTL3Za6lapg5/w==
 
+hosted-git-info@^3.0.6:
+  version "3.0.7"
+  resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-3.0.7.tgz#a30727385ea85acfcee94e0aad9e368c792e036c"
+  integrity sha512-fWqc0IcuXs+BmE9orLDyVykAG9GJtGLGuZAAqgcckPgv5xad4AcXGIv8galtQvlwutxSlaMcdw7BUtq2EIvqCQ==
+  dependencies:
+    lru-cache "^6.0.0"
+
 hpack.js@^2.1.6:
   version "2.1.6"
   resolved "https://registry.yarnpkg.com/hpack.js/-/hpack.js-2.1.6.tgz#87774c0949e513f42e84575b3c45681fade2a0b2"
@@ -4856,17 +5072,15 @@
     relateurl "0.2.x"
     uglify-js "3.4.x"
 
-htmlparser2@^3.10.1:
-  version "3.10.1"
-  resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f"
-  integrity sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==
+htmlparser2@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-5.0.1.tgz#7daa6fc3e35d6107ac95a4fc08781f091664f6e7"
+  integrity sha512-vKZZra6CSe9qsJzh0BjBGXo8dvzNsq/oGvsjfRdOrrryfeD9UOBEEQdeoqCRmKZchF5h2zOBMQ6YuQ0uRUmdbQ==
   dependencies:
-    domelementtype "^1.3.1"
-    domhandler "^2.3.0"
-    domutils "^1.5.1"
-    entities "^1.1.1"
-    inherits "^2.0.1"
-    readable-stream "^3.1.1"
+    domelementtype "^2.0.1"
+    domhandler "^3.3.0"
+    domutils "^2.4.2"
+    entities "^2.0.0"
 
 http-cache-semantics@^4.0.0:
   version "4.1.0"
@@ -4943,10 +5157,10 @@
     agent-base "^4.3.0"
     debug "^3.1.0"
 
-human-signals@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3"
-  integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==
+human-signals@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0"
+  integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==
 
 iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@^0.4.4:
   version "0.4.24"
@@ -4977,7 +5191,7 @@
   resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc"
   integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==
 
-ignore@^5.1.1:
+ignore@^5.1.1, ignore@^5.1.4:
   version "5.1.8"
   resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57"
   integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==
@@ -4990,6 +5204,14 @@
     parent-module "^1.0.0"
     resolve-from "^4.0.0"
 
+import-fresh@^3.2.1:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b"
+  integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==
+  dependencies:
+    parent-module "^1.0.0"
+    resolve-from "^4.0.0"
+
 import-lazy@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43"
@@ -5040,7 +5262,12 @@
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
   integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=
 
-ini@^1.3.4, ini@^1.3.5, ini@~1.3.0:
+ini@1.3.7:
+  version "1.3.7"
+  resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.7.tgz#a09363e1911972ea16d7a8851005d84cf09a9a84"
+  integrity sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ==
+
+ini@^1.3.4, ini@~1.3.0:
   version "1.3.5"
   resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"
   integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==
@@ -5084,40 +5311,21 @@
     strip-ansi "^5.1.0"
     through "^2.3.6"
 
-inquirer@^7.0.0:
-  version "7.0.3"
-  resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.0.3.tgz#f9b4cd2dff58b9f73e8d43759436ace15bed4567"
-  integrity sha512-+OiOVeVydu4hnCGLCSX+wedovR/Yzskv9BFqUNNKq9uU2qg7LCcCo3R86S2E7WLo0y/x2pnEZfZe1CoYnORUAw==
+inquirer@^7.3.3:
+  version "7.3.3"
+  resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.3.3.tgz#04d176b2af04afc157a83fd7c100e98ee0aad003"
+  integrity sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==
   dependencies:
     ansi-escapes "^4.2.1"
-    chalk "^2.4.2"
+    chalk "^4.1.0"
     cli-cursor "^3.1.0"
-    cli-width "^2.0.0"
+    cli-width "^3.0.0"
     external-editor "^3.0.3"
     figures "^3.0.0"
-    lodash "^4.17.15"
-    mute-stream "0.0.8"
-    run-async "^2.2.0"
-    rxjs "^6.5.3"
-    string-width "^4.1.0"
-    strip-ansi "^5.1.0"
-    through "^2.3.6"
-
-inquirer@^7.1.0:
-  version "7.2.0"
-  resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.2.0.tgz#63ce99d823090de7eb420e4bb05e6f3449aa389a"
-  integrity sha512-E0c4rPwr9ByePfNlTIB8z51kK1s2n6jrHuJeEHENl/sbq2G/S1auvibgEwNR4uSyiU+PiYHqSwsgGiXjG8p5ZQ==
-  dependencies:
-    ansi-escapes "^4.2.1"
-    chalk "^3.0.0"
-    cli-cursor "^3.1.0"
-    cli-width "^2.0.0"
-    external-editor "^3.0.3"
-    figures "^3.0.0"
-    lodash "^4.17.15"
+    lodash "^4.17.19"
     mute-stream "0.0.8"
     run-async "^2.4.0"
-    rxjs "^6.5.3"
+    rxjs "^6.6.0"
     string-width "^4.1.0"
     strip-ansi "^6.0.0"
     through "^2.3.6"
@@ -5180,10 +5388,10 @@
   resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be"
   integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==
 
-is-callable@^1.1.4, is-callable@^1.1.5:
-  version "1.1.5"
-  resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.5.tgz#f7e46b596890456db74e7f6e976cb3273d06faab"
-  integrity sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q==
+is-callable@^1.1.4, is-callable@^1.2.2:
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.2.tgz#c7c6715cd22d4ddb48d3e19970223aceabb080d9"
+  integrity sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA==
 
 is-ci@^1.0.10:
   version "1.2.1"
@@ -5199,6 +5407,13 @@
   dependencies:
     ci-info "^2.0.0"
 
+is-core-module@^2.1.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.2.0.tgz#97037ef3d52224d85163f5597b2b63d9afed981a"
+  integrity sha512-XRAfAdyyY5F5cOXn7hYQDqh2Xmii+DEfIcQGxK/uNwMHhIkPWO0g8msXcbzLe+MpGoR951MlqM/2iIlU4vKDdQ==
+  dependencies:
+    has "^1.0.3"
+
 is-data-descriptor@^0.1.4:
   version "0.1.4"
   resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56"
@@ -5333,7 +5548,7 @@
     global-dirs "^0.1.0"
     is-path-inside "^1.0.0"
 
-is-installed-globally@^0.3.1:
+is-installed-globally@^0.3.2:
   version "0.3.2"
   resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.3.2.tgz#fd3efa79ee670d1187233182d5b0a1dd00313141"
   integrity sha512-wZ8x1js7Ia0kecP/CHM/3ABkAmujX7WPvQk6uu3Fly/Mk44pySulQpnHG46OMjHGXApINnV4QhY3SWnECO2z5g==
@@ -5341,15 +5556,20 @@
     global-dirs "^2.0.1"
     is-path-inside "^3.0.1"
 
+is-negative-zero@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.1.tgz#3de746c18dda2319241a53675908d8f766f11c24"
+  integrity sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w==
+
 is-npm@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-1.0.0.tgz#f2fb63a65e4905b406c86072765a1a4dc793b9f4"
   integrity sha1-8vtjpl5JBbQGyGBydloaTceTufQ=
 
-is-npm@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-4.0.0.tgz#c90dd8380696df87a7a6d823c20d0b12bbe3c84d"
-  integrity sha512-96ECIfh9xtDDlPylNPXhzjsykHsMJZ18ASpaWzQyBr4YRTcVjUvzaHayDAES2oU/3KpljhHUjtSRNiDwi0F0ig==
+is-npm@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-5.0.0.tgz#43e8d65cc56e1b67f8d47262cf667099193f45a8"
+  integrity sha512-WW/rQLOazUq+ST/bCAVBp/2oMERWLsR7OrKyt052dNDk4DHcDE0/7QSXITlmi+VBcV13DfIbysG3tZJm5RfdBA==
 
 is-number@^2.1.0:
   version "2.1.0"
@@ -5370,6 +5590,11 @@
   resolved "https://registry.yarnpkg.com/is-number/-/is-number-4.0.0.tgz#0026e37f5454d73e356dfe6564699867c6a7f0ff"
   integrity sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ==
 
+is-number@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
+  integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
+
 is-obj@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f"
@@ -5453,12 +5678,12 @@
   resolved "https://registry.yarnpkg.com/is-redirect/-/is-redirect-1.0.0.tgz#1d03dded53bd8db0f30c26e4f95d36fc7c87dc24"
   integrity sha1-HQPd7VO9jbDzDCbk+V02/HyH3CQ=
 
-is-regex@^1.0.5:
-  version "1.0.5"
-  resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.0.5.tgz#39d589a358bf18967f726967120b8fc1aed74eae"
-  integrity sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==
+is-regex@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.1.tgz#c6f98aacc546f6cec5468a07b7b153ab564a57b9"
+  integrity sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg==
   dependencies:
-    has "^1.0.3"
+    has-symbols "^1.0.1"
 
 is-retry-allowed@^1.0.0:
   version "1.1.0"
@@ -5613,10 +5838,10 @@
   resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
   integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM=
 
-jsdoctypeparser@^6.1.0:
-  version "6.1.0"
-  resolved "https://registry.yarnpkg.com/jsdoctypeparser/-/jsdoctypeparser-6.1.0.tgz#acfb936c26300d98f1405cb03e20b06748e512a8"
-  integrity sha512-UCQBZ3xCUBv/PLfwKAJhp6jmGOSLFNKzrotXGNgbKhWvz27wPsCsVeP7gIcHPElQw2agBmynAitXqhxR58XAmA==
+jsdoctypeparser@^9.0.0:
+  version "9.0.0"
+  resolved "https://registry.yarnpkg.com/jsdoctypeparser/-/jsdoctypeparser-9.0.0.tgz#8c97e2fb69315eb274b0f01377eaa5c940bd7b26"
+  integrity sha512-jrTA2jJIL6/DAEILBEh2/w9QxCuwmvNXIry39Ay/HVfhE3o2yVV0U44blYkqdHA/OKloJEqvJy0xU+GSdE2SIw==
 
 jsesc@^1.3.0:
   version "1.3.0"
@@ -5643,6 +5868,11 @@
   resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9"
   integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==
 
+json-parse-even-better-errors@^2.3.0:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d"
+  integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==
+
 json-schema-traverse@^0.4.1:
   version "0.4.1"
   resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
@@ -5663,6 +5893,13 @@
   resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
   integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=
 
+json5@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe"
+  integrity sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==
+  dependencies:
+    minimist "^1.2.0"
+
 json5@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.0.tgz#e7a0c62c48285c628d20a10b85c89bb807c32850"
@@ -5742,7 +5979,7 @@
   dependencies:
     package-json "^4.0.0"
 
-latest-version@^5.0.0:
+latest-version@^5.1.0:
   version "5.1.0"
   resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-5.1.0.tgz#119dfe908fe38d15dfa43ecd13fa12ec8832face"
   integrity sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA==
@@ -5775,13 +6012,13 @@
   dependencies:
     readable-stream "^2.0.5"
 
-levn@^0.3.0, levn@~0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee"
-  integrity sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=
+levn@^0.4.1:
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade"
+  integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==
   dependencies:
-    prelude-ls "~1.1.2"
-    type-check "~0.3.2"
+    prelude-ls "^1.2.1"
+    type-check "~0.4.0"
 
 lines-and-columns@^1.1.6:
   version "1.1.6"
@@ -5942,6 +6179,11 @@
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.12.tgz#a712c74fdc31f7ecb20fe44f157d802d208097ef"
   integrity sha512-+CiwtLnsJhX03p20mwXuvhoebatoh5B3tt+VvYlrPgZC1g36y+RRbkufX95Xa+X4I59aWEacDFYwnJZiyBh9gA==
 
+lodash@^4.17.19, lodash@^4.17.20:
+  version "4.17.20"
+  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52"
+  integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==
+
 log-symbols@^1.0.0, log-symbols@^1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-1.0.2.tgz#376ff7b58ea3086a0f09facc74617eca501e1a18"
@@ -6026,6 +6268,13 @@
     pseudomap "^1.0.2"
     yallist "^2.1.2"
 
+lru-cache@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94"
+  integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==
+  dependencies:
+    yallist "^4.0.0"
+
 macos-release@^2.2.0:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/macos-release/-/macos-release-2.3.0.tgz#eb1930b036c0800adebccd5f17bc4c12de8bb71f"
@@ -6142,24 +6391,22 @@
     redent "^1.0.0"
     trim-newlines "^1.0.0"
 
-meow@^7.0.0:
-  version "7.0.1"
-  resolved "https://registry.yarnpkg.com/meow/-/meow-7.0.1.tgz#1ed4a0a50b3844b451369c48362eb0515f04c1dc"
-  integrity sha512-tBKIQqVrAHqwit0vfuFPY3LlzJYkEOFyKa3bPgxzNl6q/RtN8KQ+ALYEASYuFayzSAsjlhXj/JZ10rH85Q6TUw==
+meow@^8.0.0:
+  version "8.0.0"
+  resolved "https://registry.yarnpkg.com/meow/-/meow-8.0.0.tgz#1aa10ee61046719e334ffdc038bb5069250ec99a"
+  integrity sha512-nbsTRz2fwniJBFgUkcdISq8y/q9n9VbiHYbfwklFh5V4V2uAcxtKQkDc0yCLPM/kP0d+inZBewn3zJqewHE7kg==
   dependencies:
     "@types/minimist" "^1.2.0"
-    arrify "^2.0.1"
-    camelcase "^6.0.0"
     camelcase-keys "^6.2.2"
     decamelize-keys "^1.1.0"
     hard-rejection "^2.1.0"
-    minimist-options "^4.0.2"
-    normalize-package-data "^2.5.0"
+    minimist-options "4.1.0"
+    normalize-package-data "^3.0.0"
     read-pkg-up "^7.0.1"
     redent "^3.0.0"
     trim-newlines "^3.0.0"
-    type-fest "^0.13.1"
-    yargs-parser "^18.1.3"
+    type-fest "^0.18.0"
+    yargs-parser "^20.2.3"
 
 merge-descriptors@1.0.1:
   version "1.0.1"
@@ -6183,6 +6430,11 @@
   resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.2.3.tgz#7ee99dbd69bb6481689253f018488a1b902b0ed5"
   integrity sha512-gdUU1Fwj5ep4kplwcmftruWofEFt6lfpkkr3h860CXbAB9c3hGb55EOL2ali0Td5oebvW0E1+3Sr+Ur7XfKpRA==
 
+merge2@^1.3.0:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
+  integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
+
 methods@~1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
@@ -6226,6 +6478,14 @@
     snapdragon "^0.8.1"
     to-regex "^3.0.2"
 
+micromatch@^4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.2.tgz#4fcb0999bf9fbc2fcbdd212f6d629b9a56c39259"
+  integrity sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==
+  dependencies:
+    braces "^3.0.1"
+    picomatch "^2.0.5"
+
 mime-db@1.40.0, mime-db@^1.28.0:
   version "1.40.0"
   resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.40.0.tgz#a65057e998db090f732a68f6c276d387d4126c32"
@@ -6304,7 +6564,7 @@
   dependencies:
     brace-expansion "^1.1.7"
 
-minimist-options@^4.0.2:
+minimist-options@4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/minimist-options/-/minimist-options-4.1.0.tgz#c0655713c53a8a2ebd77ffa247d342c40f010619"
   integrity sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==
@@ -6368,7 +6628,7 @@
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a"
   integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==
 
-ms@^2.1.1:
+ms@2.1.2, ms@^2.1.1:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
   integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
@@ -6544,6 +6804,16 @@
     semver "2 || 3 || 4 || 5"
     validate-npm-package-license "^3.0.1"
 
+normalize-package-data@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-3.0.0.tgz#1f8a7c423b3d2e85eb36985eaf81de381d01301a"
+  integrity sha512-6lUjEI0d3v6kFrtgA/lOx4zHCWULXsFNIjHolnZCKCTLA6m/G625cdn3O7eNmT0iD3jfo6HZ9cdImGZwf21prw==
+  dependencies:
+    hosted-git-info "^3.0.6"
+    resolve "^1.17.0"
+    semver "^7.3.2"
+    validate-npm-package-license "^3.0.1"
+
 normalize-path@^2.0.0, normalize-path@^2.0.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9"
@@ -6581,7 +6851,7 @@
   dependencies:
     path-key "^2.0.0"
 
-npm-run-path@^4.0.0:
+npm-run-path@^4.0.1:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea"
   integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==
@@ -6627,10 +6897,10 @@
     define-property "^0.2.5"
     kind-of "^3.0.3"
 
-object-inspect@^1.7.0:
-  version "1.7.0"
-  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.7.0.tgz#f4f6bd181ad77f006b5ece60bd0b6f398ff74a67"
-  integrity sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==
+object-inspect@^1.8.0:
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.9.0.tgz#c90521d74e1127b67266ded3394ad6116986533a"
+  integrity sha512-i3Bp9iTqwhaLZBxGkRfo5ZbE07BQRT7MGu8+nNgwW9ItGp1TzCTw2DLEoWwjClxBjOFI/hWljTAmYGCEwmtnOw==
 
 object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.1:
   version "1.1.1"
@@ -6654,10 +6924,15 @@
     has-symbols "^1.0.0"
     object-keys "^1.0.11"
 
-object.entries-ponyfill@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/object.entries-ponyfill/-/object.entries-ponyfill-1.0.1.tgz#29abdf77cbfbd26566dd1aa24e9d88f65433d256"
-  integrity sha1-Kavfd8v70mVm3RqiTp2I9lQz0lY=
+object.assign@^4.1.1:
+  version "4.1.2"
+  resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.2.tgz#0ed54a342eceb37b38ff76eb831a0e788cb63940"
+  integrity sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==
+  dependencies:
+    call-bind "^1.0.0"
+    define-properties "^1.1.3"
+    has-symbols "^1.0.1"
+    object-keys "^1.1.1"
 
 object.omit@^2.0.0:
   version "2.0.1"
@@ -6674,14 +6949,14 @@
   dependencies:
     isobject "^3.0.1"
 
-object.values@^1.1.0:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.1.tgz#68a99ecde356b7e9295a3c5e0ce31dc8c953de5e"
-  integrity sha512-WTa54g2K8iu0kmS/us18jEmdv1a4Wi//BZ/DTVYEcH0XhLM5NYdpDHja3gt57VrZLcNAO2WGA+KpWsDBaHt6eA==
+object.values@^1.1.1:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.2.tgz#7a2015e06fcb0f546bd652486ce8583a4731c731"
+  integrity sha512-MYC0jvJopr8EK6dPBiO8Nb9mvjdypOachO5REGk6MXzujbBrAisKo3HmdEI6kZDL6fC31Mwee/5YbtMebixeag==
   dependencies:
+    call-bind "^1.0.0"
     define-properties "^1.1.3"
-    es-abstract "^1.17.0-next.1"
-    function-bind "^1.1.1"
+    es-abstract "^1.18.0-next.1"
     has "^1.0.3"
 
 obuf@^1.0.0, obuf@^1.1.1:
@@ -6730,10 +7005,10 @@
   dependencies:
     mimic-fn "^1.0.0"
 
-onetime@^5.1.0:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.0.tgz#fff0f3c91617fe62bb50189636e99ac8a6df7be5"
-  integrity sha512-5NcSkPHhwTVFIQN+TUqXoS5+dlElHXdpAWu9I0HP20YOtIi+aZ0Ct82jdlILDxjLEAWwvm+qj1m6aEtsDVmm6Q==
+onetime@^5.1.0, onetime@^5.1.2:
+  version "5.1.2"
+  resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e"
+  integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==
   dependencies:
     mimic-fn "^2.1.0"
 
@@ -6752,17 +7027,17 @@
     minimist "~0.0.1"
     wordwrap "~0.0.2"
 
-optionator@^0.8.3:
-  version "0.8.3"
-  resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495"
-  integrity sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==
+optionator@^0.9.1:
+  version "0.9.1"
+  resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.1.tgz#4f236a6373dae0566a6d43e1326674f50c291499"
+  integrity sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==
   dependencies:
-    deep-is "~0.1.3"
-    fast-levenshtein "~2.0.6"
-    levn "~0.3.0"
-    prelude-ls "~1.1.2"
-    type-check "~0.3.2"
-    word-wrap "~1.2.3"
+    deep-is "^0.1.3"
+    fast-levenshtein "^2.0.6"
+    levn "^0.4.1"
+    prelude-ls "^1.2.1"
+    type-check "^0.4.0"
+    word-wrap "^1.2.3"
 
 ordered-read-streams@^0.3.0:
   version "0.3.0"
@@ -6957,13 +7232,13 @@
     json-parse-better-errors "^1.0.1"
 
 parse-json@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.0.0.tgz#73e5114c986d143efa3712d4ea24db9a4266f60f"
-  integrity sha512-OOY5b7PAEFV0E2Fir1KOkxchnZNCdowAJgQ5NuxjpBKTRP3pQhwkrkxqQjeoKJ+fO7bCpmIZaogI4eZGDMEGOw==
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.1.0.tgz#f96088cdf24a8faa9aea9a009f2d9d942c999646"
+  integrity sha512-+mi/lmVVNKFNVyLXV31ERiy2CY5E1/F6QtJFEzoChPRwwngMNXRDQ9GJ5WdE2Z2P4AujsOi0/+2qHID68KwfIQ==
   dependencies:
     "@babel/code-frame" "^7.0.0"
     error-ex "^1.3.1"
-    json-parse-better-errors "^1.0.1"
+    json-parse-even-better-errors "^2.3.0"
     lines-and-columns "^1.1.6"
 
 parse-passwd@^1.0.0:
@@ -7089,6 +7364,11 @@
   dependencies:
     pify "^3.0.0"
 
+path-type@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
+  integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
+
 peek-stream@^1.1.0:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/peek-stream/-/peek-stream-1.1.3.tgz#3b35d84b7ccbbd262fff31dc10da56856ead6d67"
@@ -7118,6 +7398,11 @@
   resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
   integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=
 
+picomatch@^2.0.5, picomatch@^2.2.1:
+  version "2.2.2"
+  resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad"
+  integrity sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==
+
 pify@^2.0.0, pify@^2.3.0:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
@@ -7441,10 +7726,10 @@
   resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab"
   integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=
 
-prelude-ls@~1.1.2:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
-  integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=
+prelude-ls@^1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
+  integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
 
 prepend-http@^1.0.1:
   version "1.0.4"
@@ -7468,11 +7753,16 @@
   dependencies:
     fast-diff "^1.1.2"
 
-prettier@2.0.5, prettier@^2.0.4:
+prettier@2.0.5:
   version "2.0.5"
   resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.0.5.tgz#d6d56282455243f2f92cc1716692c08aa31522d4"
   integrity sha512-7PtVymN48hGcO4fGjybyBSIWDsLU4H4XlvOHfq91pz9kkGlonzwTfYkaIEwiRg/dAJF9YlbsduBAgtYLi+8cFg==
 
+prettier@^2.1.2:
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.2.1.tgz#795a1a78dd52f073da0cd42b21f9c91381923ff5"
+  integrity sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q==
+
 pretty-bytes@^4.0.2:
   version "4.0.2"
   resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-4.0.2.tgz#b2bf82e7350d65c6c33aa95aaa5a4f6327f61cd9"
@@ -7578,10 +7868,10 @@
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
   integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
 
-pupa@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/pupa/-/pupa-2.0.1.tgz#dbdc9ff48ffbea4a26a069b6f9f7abb051008726"
-  integrity sha512-hEJH0s8PXLY/cdXh66tNEQGndDrIKNqNC5xmrysZy3i5C3oEoLna7YAOad+7u125+zH1HNXUmGEkrhb3c2VriA==
+pupa@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/pupa/-/pupa-2.1.1.tgz#f5e8fd4afc2c5d97828faa523549ed8744a20d62"
+  integrity sha512-l1jNAspIBSFqbT+y+5FosojNpVpF94nlI+wDUpqP9enwOTfHx9f0gh5nB96vl+6yTpsJsypeNrwfzPrKuHB41A==
   dependencies:
     escape-goat "^2.0.0"
 
@@ -7856,12 +8146,7 @@
     extend-shallow "^3.0.2"
     safe-regex "^1.1.0"
 
-regexpp@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-2.0.1.tgz#8d19d31cf632482b589049f8281f93dbcba4d07f"
-  integrity sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw==
-
-regexpp@^3.0.0:
+regexpp@^3.0.0, regexpp@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.1.0.tgz#206d0ad0a5648cffbdb8ae46438f3dc51c9f78e2"
   integrity sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q==
@@ -7878,10 +8163,10 @@
     unicode-match-property-ecmascript "^1.0.4"
     unicode-match-property-value-ecmascript "^1.1.0"
 
-regextras@^0.7.0:
-  version "0.7.0"
-  resolved "https://registry.yarnpkg.com/regextras/-/regextras-0.7.0.tgz#2298bef8cfb92b1b7e3b9b12aa8f69547b7d71e4"
-  integrity sha512-ds+fL+Vhl918gbAUb0k2gVKbTZLsg84Re3DI6p85Et0U0tYME3hyW4nMK8Px4dtDaBA2qNjvG5uWyW7eK5gfmw==
+regextras@^0.7.1:
+  version "0.7.1"
+  resolved "https://registry.yarnpkg.com/regextras/-/regextras-0.7.1.tgz#be95719d5f43f9ef0b9fa07ad89b7c606995a3b2"
+  integrity sha512-9YXf6xtW+qzQ+hcMQXx95MOvfqXFgsKDZodX3qZB0x2n5Z94ioetIITsBtvJbiOyxa/6s9AtyweBLCdPmPko/w==
 
 registry-auth-token@^3.0.1:
   version "3.4.0"
@@ -7892,9 +8177,9 @@
     safe-buffer "^5.0.1"
 
 registry-auth-token@^4.0.0:
-  version "4.1.1"
-  resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-4.1.1.tgz#40a33be1e82539460f94328b0f7f0f84c16d9479"
-  integrity sha512-9bKS7nTl9+/A1s7tnPeGrUpRcVY+LUh7bfFgzpndALdPfXQBfQV77rQVtqgUV3ti4vc/Ik81Ex8UJDWDQ12zQA==
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-4.2.1.tgz#6d7b4006441918972ccd5fedcd41dc322c79b250"
+  integrity sha512-6gkSb4U6aWJB4SF2ZvLb76yCBjcvufXBqvvEx1HbmKPkutswjW1xNVRY0+daljIYRbogN7O0etYSlbiaEQyMyw==
   dependencies:
     rc "^1.2.8"
 
@@ -8037,11 +8322,12 @@
   dependencies:
     path-parse "^1.0.6"
 
-resolve@^1.12.0, resolve@^1.13.1:
-  version "1.15.1"
-  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.15.1.tgz#27bdcdeffeaf2d6244b95bb0f9f4b4653451f3e8"
-  integrity sha512-84oo6ZTtoTUpjgNEr5SJyzQhzL72gaRodsSfyxC/AXRvwu0Yse9H8eF9IpGo7b8YetZhlI6v7ZQ6bKBFV/6S7w==
+resolve@^1.13.1, resolve@^1.17.0:
+  version "1.19.0"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.19.0.tgz#1af5bf630409734a067cae29318aac7fa29a267c"
+  integrity sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg==
   dependencies:
+    is-core-module "^2.1.0"
     path-parse "^1.0.6"
 
 resolve@^1.5.0:
@@ -8087,7 +8373,12 @@
   resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc"
   integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==
 
-rimraf@2.6.3, rimraf@^2.2.8, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2, rimraf@~2.6.2:
+reusify@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"
+  integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
+
+rimraf@^2.2.8, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2, rimraf@~2.6.2:
   version "2.6.3"
   resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab"
   integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==
@@ -8122,6 +8413,13 @@
     "@types/node" "^12.0.10"
     acorn "^6.1.1"
 
+rollup@^2.3.4:
+  version "2.35.1"
+  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.35.1.tgz#e6bc8d10893556a638066f89e8c97f422d03968c"
+  integrity sha512-q5KxEyWpprAIcainhVy6HfRttD9kutQpHbeqDTWnqAFNJotiojetK6uqmcydNMymBEtC4I8bCYR+J3mTMqeaUA==
+  optionalDependencies:
+    fsevents "~2.1.2"
+
 run-async@^2.0.0, run-async@^2.2.0:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.3.0.tgz#0371ab4ae0bdd720d4166d7dfda64ff7a445a6c0"
@@ -8134,6 +8432,11 @@
   resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455"
   integrity sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==
 
+run-parallel@^1.1.9:
+  version "1.1.10"
+  resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.1.10.tgz#60a51b2ae836636c81377df16cb107351bcd13ef"
+  integrity sha512-zb/1OuZ6flOlH6tQyMPUrE3x3Ulxjlo9WIVXR4yVYi4H9UXQaeIsPbLn2R3O3vQCnDKkAl2qHiuocKKX4Tz/Sw==
+
 rx@^4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/rx/-/rx-4.1.0.tgz#a5f13ff79ef3b740fe30aa803fb09f98805d4782"
@@ -8146,10 +8449,10 @@
   dependencies:
     tslib "^1.9.0"
 
-rxjs@^6.5.3:
-  version "6.5.4"
-  resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.5.4.tgz#e0777fe0d184cec7872df147f303572d414e211c"
-  integrity sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q==
+rxjs@^6.6.0:
+  version "6.6.3"
+  resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.3.tgz#8ca84635c4daa900c0d3967a6ee7ac60271ee552"
+  integrity sha512-trsQc+xYYXZ3urjOiJOuCOa5N3jAZ3eiSpQB5hIT8zGlL2QfnHLJ2r7GMkBGuIausdJN1OneaI6gQlsqNHHmZQ==
   dependencies:
     tslib "^1.9.0"
 
@@ -8249,11 +8552,18 @@
   resolved "https://registry.yarnpkg.com/semver/-/semver-5.6.0.tgz#7e74256fbaa49c75aa7c7a205cc22799cac80004"
   integrity sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==
 
-semver@^6.0.0, semver@^6.1.0, semver@^6.1.2, semver@^6.2.0, semver@^6.3.0:
+semver@^6.0.0, semver@^6.1.0, semver@^6.2.0, semver@^6.3.0:
   version "6.3.0"
   resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
   integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
 
+semver@^7.2.1, semver@^7.3.2, semver@^7.3.4:
+  version "7.3.4"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.4.tgz#27aaa7d2e4ca76452f98d3add093a72c943edc97"
+  integrity sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==
+  dependencies:
+    lru-cache "^6.0.0"
+
 send@0.17.1:
   version "0.17.1"
   resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8"
@@ -8380,6 +8690,11 @@
   resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"
   integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=
 
+signal-exit@^3.0.3:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c"
+  integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==
+
 simple-swizzle@^0.2.2:
   version "0.2.2"
   resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a"
@@ -8411,14 +8726,19 @@
   resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55"
   integrity sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU=
 
-slice-ansi@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-2.1.0.tgz#cacd7693461a637a5788d92a7dd4fba068e81636"
-  integrity sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==
+slash@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
+  integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==
+
+slice-ansi@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-4.0.0.tgz#500e8dd0fd55b05815086255b3195adf2a45fe6b"
+  integrity sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==
   dependencies:
-    ansi-styles "^3.2.0"
-    astral-regex "^1.0.0"
-    is-fullwidth-code-point "^2.0.0"
+    ansi-styles "^4.0.0"
+    astral-regex "^2.0.0"
+    is-fullwidth-code-point "^3.0.0"
 
 slide@^1.1.5:
   version "1.1.6"
@@ -8595,6 +8915,14 @@
     spdx-exceptions "^2.1.0"
     spdx-license-ids "^3.0.0"
 
+spdx-expression-parse@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz#cf70f50482eefdc98e3ce0a6833e4a53ceeba679"
+  integrity sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==
+  dependencies:
+    spdx-exceptions "^2.1.0"
+    spdx-license-ids "^3.0.0"
+
 spdx-license-ids@^3.0.0:
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.4.tgz#75ecd1a88de8c184ef015eafb51b5b48bfd11bb1"
@@ -8736,7 +9064,7 @@
     is-fullwidth-code-point "^2.0.0"
     strip-ansi "^5.1.0"
 
-string-width@^4.0.0, string-width@^4.1.0:
+string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0:
   version "4.2.0"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.0.tgz#952182c46cc7b2c313d1596e623992bd163b72b5"
   integrity sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==
@@ -8745,21 +9073,21 @@
     is-fullwidth-code-point "^3.0.0"
     strip-ansi "^6.0.0"
 
-string.prototype.trimleft@^2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/string.prototype.trimleft/-/string.prototype.trimleft-2.1.1.tgz#9bdb8ac6abd6d602b17a4ed321870d2f8dcefc74"
-  integrity sha512-iu2AGd3PuP5Rp7x2kEZCrB2Nf41ehzh+goo8TV7z8/XDBbsvc6HQIlUl9RjkZ4oyrW1XM5UwlGl1oVEaDjg6Ag==
+string.prototype.trimend@^1.0.1:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.3.tgz#a22bd53cca5c7cf44d7c9d5c732118873d6cd18b"
+  integrity sha512-ayH0pB+uf0U28CtjlLvL7NaohvR1amUvVZk+y3DYb0Ey2PUV5zPkkKy9+U1ndVEIXO8hNg18eIv9Jntbii+dKw==
   dependencies:
+    call-bind "^1.0.0"
     define-properties "^1.1.3"
-    function-bind "^1.1.1"
 
-string.prototype.trimright@^2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/string.prototype.trimright/-/string.prototype.trimright-2.1.1.tgz#440314b15996c866ce8a0341894d45186200c5d9"
-  integrity sha512-qFvWL3/+QIgZXVmJBfpHmxLB7xsUXz6HsUmP8+5dRaC3Q7oKUv9Vo6aMCRZC1smrtyECFsIT30PqBJ1gTjAs+g==
+string.prototype.trimstart@^1.0.1:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.3.tgz#9b4cb590e123bb36564401d59824298de50fd5aa"
+  integrity sha512-oBIBUy5lea5tt0ovtOFiEQaBkoBBkyJhZXzJYrSmDo5IUUqbOPvVezuRs/agBIdZ2p2Eo1FD6bD9USyBLfl3xg==
   dependencies:
+    call-bind "^1.0.0"
     define-properties "^1.1.3"
-    function-bind "^1.1.1"
 
 string_decoder@^1.1.1:
   version "1.3.0"
@@ -8794,7 +9122,7 @@
   dependencies:
     ansi-regex "^3.0.0"
 
-strip-ansi@^5.1.0, strip-ansi@^5.2.0:
+strip-ansi@^5.1.0:
   version "5.2.0"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae"
   integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==
@@ -8870,10 +9198,10 @@
   dependencies:
     min-indent "^1.0.0"
 
-strip-json-comments@^3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.0.1.tgz#85713975a91fb87bf1b305cca77395e40d2a64a7"
-  integrity sha512-VTyMAUfdm047mwKl+u79WIdrZxtFtn+nBxHeb844XBQ9uMNTuTHdx2hc5RiAJYqwTj3wc/xe5HLSdJSkJ+WfZw==
+strip-json-comments@^3.1.0, strip-json-comments@^3.1.1:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
+  integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
 
 strip-json-comments@~2.0.1:
   version "2.0.1"
@@ -8934,15 +9262,15 @@
     typical "^2.6.1"
     wordwrapjs "^3.0.0"
 
-table@^5.2.3:
-  version "5.4.6"
-  resolved "https://registry.yarnpkg.com/table/-/table-5.4.6.tgz#1292d19500ce3f86053b05f0e8e7e4a3bb21079e"
-  integrity sha512-wmEc8m4fjnob4gt5riFRtTu/6+4rSe12TpAELNSqHMfF3IqnA+CH37USM6/YR3qRZv7e56kAEAtd6nKZaxe0Ug==
+table@^6.0.4:
+  version "6.0.4"
+  resolved "https://registry.yarnpkg.com/table/-/table-6.0.4.tgz#c523dd182177e926c723eb20e1b341238188aa0d"
+  integrity sha512-sBT4xRLdALd+NFBvwOz8bw4b15htyythha+q+DVZqy2RS08PPC8O2sZFgJYEY7bJvbCFKccs+WIZ/cd+xxTWCw==
   dependencies:
-    ajv "^6.10.2"
-    lodash "^4.17.14"
-    slice-ansi "^2.1.0"
-    string-width "^3.0.0"
+    ajv "^6.12.4"
+    lodash "^4.17.20"
+    slice-ansi "^4.0.0"
+    string-width "^4.2.0"
 
 tar-fs@^1.12.0:
   version "1.16.3"
@@ -9025,9 +9353,9 @@
     execa "^0.7.0"
 
 term-size@^2.1.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/term-size/-/term-size-2.2.0.tgz#1f16adedfe9bdc18800e1776821734086fcc6753"
-  integrity sha512-a6sumDlzyHVJWb8+YofY4TW112G6p2FCPEAFk+59gIYHv3XHRhm9ltVQ9kli4hNWeQBwSpe8cRN25x0ROunMOw==
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/term-size/-/term-size-2.2.1.tgz#2a6a54840432c2fb6320fea0f415531e90189f54"
+  integrity sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==
 
 ternary-stream@^2.0.1:
   version "2.0.1"
@@ -9197,6 +9525,13 @@
     is-number "^3.0.0"
     repeat-string "^1.6.1"
 
+to-regex-range@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4"
+  integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==
+  dependencies:
+    is-number "^7.0.0"
+
 to-regex@^3.0.1, to-regex@^3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce"
@@ -9247,6 +9582,16 @@
   resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9"
   integrity sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==
 
+tsconfig-paths@^3.9.0:
+  version "3.9.0"
+  resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.9.0.tgz#098547a6c4448807e8fcb8eae081064ee9a3c90b"
+  integrity sha512-dRcuzokWhajtZWkQsDVKbWyY+jgcLC5sqJhg2PSgf4ZkH2aHPvaOY8YWGhmjb68b5qqTfasSsDO9k7RUiEmZAw==
+  dependencies:
+    "@types/json5" "^0.0.29"
+    json5 "^1.0.1"
+    minimist "^1.2.0"
+    strip-bom "^3.0.0"
+
 tslib@^1.8.1:
   version "1.13.0"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043"
@@ -9283,22 +9628,27 @@
   resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
   integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=
 
-type-check@~0.3.2:
-  version "0.3.2"
-  resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72"
-  integrity sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=
+type-check@^0.4.0, type-check@~0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"
+  integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==
   dependencies:
-    prelude-ls "~1.1.2"
+    prelude-ls "^1.2.1"
 
 type-detect@^4.0.0:
   version "4.0.8"
   resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
   integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==
 
-type-fest@^0.13.1:
-  version "0.13.1"
-  resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.13.1.tgz#0172cb5bce80b0bd542ea348db50c7e21834d934"
-  integrity sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==
+type-fest@^0.11.0:
+  version "0.11.0"
+  resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.11.0.tgz#97abf0872310fed88a5c466b25681576145e33f1"
+  integrity sha512-OdjXJxnCN1AvyLSzeKIgXTXxV+99ZuXl3Hpo9XpJAv9MBcHrrJOQ5kV7ypXOuQie+AmWG25hLbiKdwYTifzcfQ==
+
+type-fest@^0.18.0:
+  version "0.18.1"
+  resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.18.1.tgz#db4bc151a4a2cf4eebf9add5db75508db6cc841f"
+  integrity sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==
 
 type-fest@^0.6.0:
   version "0.6.0"
@@ -9330,10 +9680,10 @@
   resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
   integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
 
-typescript@3.9.5:
-  version "3.9.5"
-  resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.5.tgz#586f0dba300cde8be52dd1ac4f7e1009c1b13f36"
-  integrity sha512-hSAifV3k+i6lEoCJ2k6R2Z/rp/H3+8sdmcn5NrS3/3kE7+RyZXm9aqvxWqjEXHAd8b0pShatpcdMTvEdvAJltQ==
+typescript@4.0.5:
+  version "4.0.5"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.0.5.tgz#ae9dddfd1069f1cb5beb3ef3b2170dd7c1332389"
+  integrity sha512-ywmr/VrTVCmNTJ6iV2LwIrfG1P+lv6luD8sUJs+2eI9NLGigaN+nUQc13iHqisq7bra9lnmUSYqbJvegraBOPQ==
 
 typical@^2.6.1:
   version "2.6.1"
@@ -9495,22 +9845,23 @@
     semver-diff "^2.0.0"
     xdg-basedir "^3.0.0"
 
-update-notifier@^4.1.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-4.1.0.tgz#4866b98c3bc5b5473c020b1250583628f9a328f3"
-  integrity sha512-w3doE1qtI0/ZmgeoDoARmI5fjDoT93IfKgEGqm26dGUOh8oNpaSTsGNdYRN/SjOuo10jcJGwkEL3mroKzktkew==
+update-notifier@^5.0.0:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-5.0.1.tgz#1f92d45fb1f70b9e33880a72dd262bc12d22c20d"
+  integrity sha512-BuVpRdlwxeIOvmc32AGYvO1KVdPlsmqSh8KDDBxS6kDE5VR7R8OMP1d8MdhaVBvxl4H3551k9akXr0Y1iIB2Wg==
   dependencies:
     boxen "^4.2.0"
-    chalk "^3.0.0"
+    chalk "^4.1.0"
     configstore "^5.0.1"
     has-yarn "^2.1.0"
     import-lazy "^2.1.0"
     is-ci "^2.0.0"
-    is-installed-globally "^0.3.1"
-    is-npm "^4.0.0"
+    is-installed-globally "^0.3.2"
+    is-npm "^5.0.0"
     is-yarn-global "^0.3.0"
-    latest-version "^5.0.0"
-    pupa "^2.0.1"
+    latest-version "^5.1.0"
+    pupa "^2.1.1"
+    semver "^7.3.2"
     semver-diff "^3.1.1"
     xdg-basedir "^4.0.0"
 
@@ -9881,7 +10232,7 @@
     p-try "^2.1.0"
     pify "^4.0.1"
 
-word-wrap@~1.2.3:
+word-wrap@^1.2.3:
   version "1.2.3"
   resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
   integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
@@ -9932,13 +10283,6 @@
     signal-exit "^3.0.2"
     typedarray-to-buffer "^3.1.5"
 
-write@1.0.3:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/write/-/write-1.0.3.tgz#0800e14523b923a387e415123c865616aae0f5c3"
-  integrity sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig==
-  dependencies:
-    mkdirp "^0.5.1"
-
 ws@^7.1.2:
   version "7.2.1"
   resolved "https://registry.yarnpkg.com/ws/-/ws-7.2.1.tgz#03ed52423cd744084b2cf42ed197c8b65a936b8e"
@@ -9998,13 +10342,15 @@
   resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.0.3.tgz#b4b049e314be545e3ce802236d6cd22cd91c3de9"
   integrity sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==
 
-yargs-parser@^18.1.3:
-  version "18.1.3"
-  resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0"
-  integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==
-  dependencies:
-    camelcase "^5.0.0"
-    decamelize "^1.2.0"
+yallist@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
+  integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
+
+yargs-parser@^20.2.3:
+  version "20.2.4"
+  resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.4.tgz#b42890f14566796f85ae8e3a25290d205f154a54"
+  integrity sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==
 
 yauzl@^2.10.0:
   version "2.10.0"